Compare commits
58 Commits
8c3b3fc6ea
...
v0.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f754831a | ||
| 93147ffc46 | |||
|
|
72d31fd143 | ||
|
|
8573e87bf6 | ||
| 67a768ea22 | |||
|
|
331c4c8759 | ||
| f6c5949247 | |||
|
|
d6e64763cc | ||
| 589d1f09e8 | |||
|
|
06b3839c3a | ||
| 99f14ca170 | |||
|
|
da82f31d46 | ||
| ffbff8cca5 | |||
|
|
bcb1fa3872 | ||
| bf9a8755f0 | |||
| 90ef970d14 | |||
|
|
eb75654403 | ||
|
|
c52c389f0c | ||
|
|
513467470c | ||
|
|
6976b773bd | ||
|
|
ac2492e7c1 | ||
| 65dc672186 | |||
|
|
1db6b2596e | ||
|
|
64b5d384ed | ||
|
|
235e4afbe3 | ||
|
|
649762e6c6 | ||
|
|
8780b7c9b1 | ||
| d77e4af1e2 | |||
|
|
718e0c55c9 | ||
|
|
dcf76bb773 | ||
|
|
4faf4f73b0 | ||
|
|
0808c4d972 | ||
|
|
42211439c9 | ||
|
|
fb6c0e3d90 | ||
|
|
2cfd42b606 | ||
|
|
6d43bdea16 | ||
|
|
c6885a069b | ||
|
|
38eb9ee398 | ||
|
|
f71acfc73e | ||
|
|
10de5d21ad | ||
|
|
7eadfbbb0c | ||
|
|
063b03ce25 | ||
| f47eb4cdf3 | |||
|
|
9a20467438 | ||
|
|
cb5458c9fc | ||
|
|
bc6488f063 | ||
| 9c3f659e96 | |||
|
|
2bea5bb489 | ||
|
|
4f1ee11fa3 | ||
|
|
8c6e5d24ac | ||
| 021215ed94 | |||
|
|
303c45cab1 | ||
|
|
587f392b8b | ||
|
|
5120eef776 | ||
|
|
fcc6b70e84 | ||
|
|
67d4dba37f | ||
|
|
afd8a3e9d0 | ||
|
|
2aa026b1d5 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -12,5 +12,5 @@
|
||||
|
||||
# Goose CLI migration config (only needed for running goose manually)
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
DEPLOY_DIR: /home/ryan/c4
|
||||
DEPLOY_DIR: /home/ryan/games
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -18,6 +18,9 @@ jobs:
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Generate templ
|
||||
run: go tool templ generate
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
@@ -30,6 +33,9 @@ jobs:
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Generate templ
|
||||
run: go tool templ generate
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
|
||||
@@ -42,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: |
|
||||
@@ -53,4 +61,8 @@ jobs:
|
||||
mkdir -p $DEPLOY_DIR/data
|
||||
|
||||
- name: Rebuild and restart
|
||||
run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans
|
||||
run: |
|
||||
cd $DEPLOY_DIR
|
||||
VERSION=$(git describe --tags --always)
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
VERSION=$VERSION COMMIT=$COMMIT docker compose up -d --build --remove-orphans
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
!.gitignore
|
||||
|
||||
!*.go
|
||||
!*.templ
|
||||
!*.sql
|
||||
!go.sum
|
||||
!go.mod
|
||||
@@ -18,10 +19,12 @@
|
||||
|
||||
!.env.example
|
||||
!LICENSE
|
||||
!AGENTS.md
|
||||
|
||||
!assets/**/*
|
||||
|
||||
# Generated CSS stays out of version control
|
||||
# Generated files stay out of version control
|
||||
*_templ.go
|
||||
assets/css/output.css
|
||||
|
||||
# Deploy scripts and configs
|
||||
|
||||
@@ -35,7 +35,7 @@ formatters:
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/ryanhamamura/c4
|
||||
- github.com/ryanhamamura/games
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
|
||||
253
AGENTS.md
Normal file
253
AGENTS.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents working in this repository.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Development
|
||||
task live # Hot-reload dev server (templ + tailwind + air)
|
||||
task build # Production build to bin/games
|
||||
task run # Build and run server
|
||||
|
||||
# Quality
|
||||
task test # Run all tests: go test ./...
|
||||
task lint # Run linter: golangci-lint run
|
||||
|
||||
# Single test
|
||||
go test -run TestName ./path/to/package
|
||||
|
||||
# Code generation
|
||||
task build:templ # Compile .templ files
|
||||
task build:styles # Build TailwindCSS
|
||||
go generate ./... # Run sqlc for DB queries
|
||||
```
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
- **Never merge PRs without explicit user approval.** Create the PR, push changes, then wait.
|
||||
- Always use PRs via `tea` CLI - never push directly to main.
|
||||
- Write semantic commit messages focusing on "why" not "what".
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
games/
|
||||
├── connect4/, snake/ # Game logic packages (pure Go)
|
||||
├── features/ # Feature modules (handlers, routes, templates)
|
||||
│ ├── auth/ # Login/register
|
||||
│ ├── c4game/ # Connect 4 UI
|
||||
│ ├── snakegame/ # Snake UI
|
||||
│ ├── lobby/ # Game lobby
|
||||
│ └── common/ # Shared components, layouts
|
||||
├── chat/ # Reusable chat room (NATS + persistence)
|
||||
├── db/ # SQLite, migrations, sqlc queries
|
||||
├── assets/ # Static files (embedded)
|
||||
└── config/, logging/, nats/, sessions/, router/ # Infrastructure
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Imports
|
||||
|
||||
Organize in three groups: stdlib, third-party, local. The linter enforces this.
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Type | Convention | Examples |
|
||||
|------|------------|----------|
|
||||
| Files | lowercase, underscores | `config_dev.go`, `handlers.go` |
|
||||
| HTTP handlers | `Handle` prefix | `HandleGamePage`, `HandleLogin` |
|
||||
| Constructors | `New` prefix | `NewStore`, `NewRoom` |
|
||||
| Getters | `Get` prefix | `GetPlayerID`, `GetGame` |
|
||||
| Setup functions | `Setup` prefix | `SetupRoutes`, `SetupLogger` |
|
||||
| Types | PascalCase | `Game`, `Player`, `Instance` |
|
||||
| Status enums | `Status` prefix | `StatusWaitingForPlayer`, `StatusInProgress` |
|
||||
| Session keys | `Key` prefix | `KeyPlayerID`, `KeyUserID` |
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. **Wrap errors with context:**
|
||||
```go
|
||||
return fmt.Errorf("loading game %s: %w", id, err)
|
||||
```
|
||||
|
||||
2. **Return (result, error) tuples:**
|
||||
```go
|
||||
func loadGame(queries *repository.Queries, id string) (*Game, error)
|
||||
```
|
||||
|
||||
3. **Best-effort operations** - use nolint comment:
|
||||
```go
|
||||
nc.Publish(subject, nil) //nolint:errcheck // best-effort notification
|
||||
```
|
||||
|
||||
4. **HTTP errors:**
|
||||
```go
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- Focus on **why**, not **how**. Avoid superfluous comments.
|
||||
- Package comments at top of primary file:
|
||||
```go
|
||||
// Package connect4 implements Connect 4 game logic, state management, and persistence.
|
||||
package connect4
|
||||
```
|
||||
- Function comments for exported functions:
|
||||
```go
|
||||
// DropPiece attempts to drop a piece in the given column.
|
||||
// Returns (row placed, success).
|
||||
func (g *Game) DropPiece(col, playerColor int) (int, bool)
|
||||
```
|
||||
|
||||
## Go Patterns
|
||||
|
||||
### Dependency Injection via Closures
|
||||
|
||||
Handlers receive dependencies and return `http.HandlerFunc`:
|
||||
|
||||
```go
|
||||
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// use store, sm here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mutex for Concurrent Access
|
||||
|
||||
```go
|
||||
type Store struct {
|
||||
games map[string]*Instance
|
||||
gamesMu sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Instance, bool) {
|
||||
s.gamesMu.RLock()
|
||||
defer s.gamesMu.RUnlock()
|
||||
inst, ok := s.games[id]
|
||||
return inst, ok
|
||||
}
|
||||
```
|
||||
|
||||
### Build Tags for Environment
|
||||
|
||||
```go
|
||||
//go:build dev
|
||||
|
||||
//go:build !dev
|
||||
```
|
||||
|
||||
### Embedded Filesystems
|
||||
|
||||
```go
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var MigrationFS embed.FS
|
||||
```
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
```go
|
||||
eg, egctx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error { return server.ListenAndServe() })
|
||||
eg.Go(func() error {
|
||||
<-egctx.Done()
|
||||
return server.Shutdown(context.Background())
|
||||
})
|
||||
return eg.Wait()
|
||||
```
|
||||
|
||||
## Templ + Datastar Patterns
|
||||
|
||||
### SSE Connection with Disabled Cancellation
|
||||
|
||||
Datastar cancels SSE on user interaction by default. Disable for persistent connections:
|
||||
|
||||
```go
|
||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
||||
```
|
||||
|
||||
### Prevent Script Duplication on SSE Patches
|
||||
|
||||
Use `templ.NewOnceHandle()` for scripts in components that get patched:
|
||||
|
||||
```go
|
||||
var scriptHandle = templ.NewOnceHandle()
|
||||
|
||||
templ MyComponent() {
|
||||
<div id="my-component">...</div>
|
||||
@scriptHandle.Once() {
|
||||
@myScript()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Classes with templ.KV
|
||||
|
||||
```go
|
||||
class={
|
||||
"status status-sm",
|
||||
templ.KV("status-success", isConnected),
|
||||
templ.KV("status-error", !isConnected),
|
||||
}
|
||||
```
|
||||
|
||||
### Datastar SSE Responses
|
||||
|
||||
```go
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.MergeFragmentTempl(components.GameBoard(game))
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Templates | templ (type-safe HTML) |
|
||||
| Reactivity | Datastar (SSE-driven) |
|
||||
| CSS | TailwindCSS v4 + daisyUI |
|
||||
| Router | chi/v5 |
|
||||
| Sessions | scs/v2 |
|
||||
| Database | SQLite (modernc.org/sqlite) |
|
||||
| Migrations | goose |
|
||||
| SQL codegen | sqlc |
|
||||
| Pub/sub | Embedded NATS |
|
||||
| Logging | zerolog |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
task test
|
||||
|
||||
# Single test
|
||||
go test -run TestDropPiece ./connect4
|
||||
|
||||
# With verbose output
|
||||
go test -v -run TestDropPiece ./connect4
|
||||
|
||||
# Test a package
|
||||
go test ./connect4/...
|
||||
```
|
||||
|
||||
Use `testutil.SetupTestDB()` for tests requiring database access.
|
||||
13
Dockerfile
13
Dockerfile
@@ -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,14 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
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"]
|
||||
|
||||
34
Taskfile.yml
34
Taskfile.yml
@@ -1,23 +1,44 @@
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
download:
|
||||
desc: Download latest client-side libs
|
||||
cmds:
|
||||
- go run cmd/downloader/main.go
|
||||
|
||||
build:templ:
|
||||
desc: Compile .templ files to Go
|
||||
cmds:
|
||||
- go tool templ generate
|
||||
sources:
|
||||
- "**/*.templ"
|
||||
generates:
|
||||
- "**/*_templ.go"
|
||||
|
||||
build:styles:
|
||||
desc: Build TailwindCSS styles
|
||||
cmds:
|
||||
- go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||
sources:
|
||||
- "assets/css/input.css"
|
||||
- "**/*.templ"
|
||||
- "**/*.go"
|
||||
generates:
|
||||
- "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:
|
||||
- build:templ
|
||||
- build:styles
|
||||
|
||||
live:templ:
|
||||
desc: Watch and recompile .templ files
|
||||
cmds:
|
||||
- go tool templ generate -watch
|
||||
|
||||
live:styles:
|
||||
desc: Watch and rebuild TailwindCSS styles
|
||||
cmds:
|
||||
@@ -28,15 +49,16 @@ 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" \
|
||||
-build.include_ext "go,templ" \
|
||||
-misc.clean_on_exit "true"
|
||||
|
||||
live:
|
||||
desc: Dev mode with hot-reload
|
||||
deps:
|
||||
- live:templ
|
||||
- live:styles
|
||||
- live:server
|
||||
|
||||
@@ -53,7 +75,7 @@ tasks:
|
||||
run:
|
||||
desc: Build and run the server
|
||||
cmds:
|
||||
- ./bin/c4
|
||||
- ./bin/games
|
||||
deps:
|
||||
- build
|
||||
|
||||
|
||||
5
assets/assets.go
Normal file
5
assets/assets.go
Normal 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
File diff suppressed because it is too large
Load Diff
9
assets/js/datastar.js
Normal file
9
assets/js/datastar.js
Normal file
File diff suppressed because one or more lines are too long
7
assets/js/datastar.js.map
Normal file
7
assets/js/datastar.js.map
Normal file
File diff suppressed because one or more lines are too long
22
assets/static_dev.go
Normal file
22
assets/static_dev.go
Normal 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
26
assets/static_prod.go
Normal 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)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package auth provides password hashing and verification using bcrypt.
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
||||
163
chat/chat.go
Normal file
163
chat/chat.go
Normal 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
|
||||
}
|
||||
78
chat/components/chat.templ
Normal file
78
chat/components/chat.templ
Normal 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});
|
||||
}
|
||||
100
cmd/downloader/main.go
Normal file
100
cmd/downloader/main.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Asset directories, relative to project root.
|
||||
const (
|
||||
jsDir = "assets/js"
|
||||
cssDir = "assets/css"
|
||||
)
|
||||
|
||||
// files maps download URLs to local destination paths.
|
||||
var files = map[string]string{
|
||||
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js": jsDir + "/datastar.js",
|
||||
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js.map": jsDir + "/datastar.js.map",
|
||||
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.mjs": cssDir + "/daisyui.mjs",
|
||||
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.mjs": cssDir + "/daisyui-theme.mjs",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("failure", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
dirs := []string{jsDir, cssDir}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return download(files)
|
||||
}
|
||||
|
||||
func download(files map[string]string) error {
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(files))
|
||||
|
||||
for url, dest := range files {
|
||||
wg.Go(func() {
|
||||
base := filepath.Base(dest)
|
||||
slog.Info("downloading...", "file", base, "url", url)
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
errCh <- fmt.Errorf("download %s: %w", base, err)
|
||||
} else {
|
||||
slog.Info("finished", "file", base)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec,noctx // static URLs, simple tool
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: status %s", url, 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
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
@@ -46,7 +47,9 @@ func getEnv(key, fallback string) string {
|
||||
}
|
||||
|
||||
func loadBase() *Config {
|
||||
godotenv.Load() //nolint:errcheck // .env file is optional
|
||||
if err := godotenv.Load(); err != nil {
|
||||
slog.Warn("no .env file found, using environment variables and defaults")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Host: getEnv("HOST", "0.0.0.0"),
|
||||
@@ -68,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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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).
|
||||
126
connect4/persist.go
Normal file
126
connect4/persist.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package connect4
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// saveGame persists the game state via upsert.
|
||||
func saveGame(queries *repository.Queries, g *Game) error {
|
||||
var winnerUserID *string
|
||||
if g.Winner != nil && g.Winner.UserID != nil {
|
||||
winnerUserID = g.Winner.UserID
|
||||
}
|
||||
|
||||
var winningCells *string
|
||||
if wc := g.WinningCellsToJSON(); wc != "" {
|
||||
winningCells = &wc
|
||||
}
|
||||
|
||||
return queries.UpsertGame(context.Background(), repository.UpsertGameParams{
|
||||
ID: g.ID,
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
RematchGameID: g.RematchGameID,
|
||||
})
|
||||
}
|
||||
|
||||
func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error {
|
||||
var userID, guestPlayerID *string
|
||||
if p.UserID != nil {
|
||||
userID = p.UserID
|
||||
} else {
|
||||
id := string(p.ID)
|
||||
guestPlayerID = &id
|
||||
}
|
||||
|
||||
return queries.CreateGamePlayer(context.Background(), repository.CreateGamePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: p.Nickname,
|
||||
Color: int64(p.Color),
|
||||
Slot: int64(slot),
|
||||
})
|
||||
}
|
||||
|
||||
func loadGame(queries *repository.Queries, id string) (*Game, error) {
|
||||
row, err := queries.GetGame(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gameFromRow(row)
|
||||
}
|
||||
|
||||
func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error) {
|
||||
rows, err := queries.GetGamePlayers(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return playersFromRows(rows), nil
|
||||
}
|
||||
|
||||
func gameFromRow(row *repository.Game) (*Game, error) {
|
||||
g := &Game{
|
||||
ID: row.ID,
|
||||
CurrentTurn: int(row.CurrentTurn),
|
||||
Status: Status(row.Status),
|
||||
}
|
||||
|
||||
if err := g.BoardFromJSON(row.Board); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row.WinningCells != nil {
|
||||
_ = g.WinningCellsFromJSON(*row.WinningCells)
|
||||
}
|
||||
|
||||
if row.RematchGameID != nil {
|
||||
g.RematchGameID = row.RematchGameID
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func playersFromRows(rows []*repository.GamePlayer) []*Player {
|
||||
players := make([]*Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
p := &Player{
|
||||
Nickname: row.Nickname,
|
||||
Color: int(row.Color),
|
||||
}
|
||||
|
||||
if row.UserID != nil {
|
||||
p.UserID = row.UserID
|
||||
p.ID = player.ID(*row.UserID)
|
||||
} else if row.GuestPlayerID != nil {
|
||||
p.ID = player.ID(*row.GuestPlayerID)
|
||||
}
|
||||
|
||||
players = append(players, p)
|
||||
}
|
||||
return players
|
||||
}
|
||||
225
connect4/store.go
Normal file
225
connect4/store.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package connect4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
type PlayerSession struct {
|
||||
Player *Player
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
games map[string]*Instance
|
||||
gamesMu sync.RWMutex
|
||||
queries *repository.Queries
|
||||
notifyFunc func(gameID string)
|
||||
}
|
||||
|
||||
func NewStore(queries *repository.Queries) *Store {
|
||||
return &Store{
|
||||
games: make(map[string]*Instance),
|
||||
queries: queries,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) SetNotifyFunc(f func(gameID string)) {
|
||||
s.notifyFunc = f
|
||||
}
|
||||
|
||||
func (s *Store) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if s.notifyFunc != nil {
|
||||
s.notifyFunc(gameID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 s.queries != nil {
|
||||
gi.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
return gi
|
||||
}
|
||||
|
||||
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 s.queries == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
g, err := loadGame(s.queries, id)
|
||||
if err != nil || g == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := loadGamePlayers(s.queries, id)
|
||||
for _, p := range players {
|
||||
switch p.Color {
|
||||
case 1:
|
||||
g.Players[0] = p
|
||||
case 2:
|
||||
g.Players[1] = p
|
||||
}
|
||||
}
|
||||
|
||||
gi = &Instance{
|
||||
game: g,
|
||||
queries: s.queries,
|
||||
notify: s.makeNotify(id),
|
||||
}
|
||||
|
||||
s.gamesMu.Lock()
|
||||
s.games[id] = gi
|
||||
s.gamesMu.Unlock()
|
||||
|
||||
return gi, true
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.gamesMu.Lock()
|
||||
delete(s.games, id)
|
||||
s.gamesMu.Unlock()
|
||||
|
||||
if s.queries != nil {
|
||||
return s.queries.DeleteGame(context.Background(), id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
game *Game
|
||||
gameMu sync.RWMutex
|
||||
notify func()
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewInstance(id string) *Instance {
|
||||
return &Instance{
|
||||
game: NewGame(id),
|
||||
notify: func() {},
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *Instance) ID() string {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game.ID
|
||||
}
|
||||
|
||||
func (gi *Instance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
var slot int
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1
|
||||
gi.game.Players[0] = ps.Player
|
||||
slot = 0
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
slot = 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.queries != nil {
|
||||
gi.savePlayer(ps.Player, slot) //nolint:errcheck
|
||||
gi.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
func (gi *Instance) GetGame() *Game {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game
|
||||
}
|
||||
|
||||
func (gi *Instance) GetPlayerColor(pid player.ID) int {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.ID == pid {
|
||||
return p.Color
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (gi *Instance) CreateRematch(s *Store) *Instance {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
newGI := s.Create()
|
||||
newID := newGI.ID()
|
||||
gi.game.RematchGameID = &newID
|
||||
|
||||
if gi.queries != nil {
|
||||
if err := gi.save(); err != nil {
|
||||
s.Delete(newID) //nolint:errcheck
|
||||
gi.game.RematchGameID = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return newGI
|
||||
}
|
||||
|
||||
func (gi *Instance) DropPiece(col int, playerColor int) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
row, ok := gi.game.DropPiece(col, playerColor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.game.CheckWin(row, col) {
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.Color == playerColor {
|
||||
gi.game.Winner = p
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if gi.game.CheckDraw() {
|
||||
// Status already set by CheckDraw
|
||||
} else {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
if gi.queries != nil {
|
||||
gi.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
12
db/migrations/007_sessions.sql
Normal file
12
db/migrations/007_sessions.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
expiry REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS sessions_expiry_idx;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
326
db/persister.go
326
db/persister.go
@@ -1,326 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
|
||||
"github.com/ryanhamamura/c4/db/repository"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
)
|
||||
|
||||
type GamePersister struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewGamePersister(q *repository.Queries) *GamePersister {
|
||||
return &GamePersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *GamePersister) SaveGame(g *game.Game) error {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := p.queries.GetGame(ctx, g.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = p.queries.CreateGame(ctx, repository.CreateGameParams{
|
||||
ID: g.ID,
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var winnerUserID sql.NullString
|
||||
if g.Winner != nil && g.Winner.UserID != nil {
|
||||
winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true}
|
||||
}
|
||||
|
||||
winningCells := sql.NullString{}
|
||||
if wc := g.WinningCellsToJSON(); wc != "" {
|
||||
winningCells = sql.NullString{String: wc, Valid: true}
|
||||
}
|
||||
|
||||
rematchGameID := sql.NullString{}
|
||||
if g.RematchGameID != nil {
|
||||
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.UpdateGame(ctx, repository.UpdateGameParams{
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
RematchGameID: rematchGameID,
|
||||
ID: g.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
|
||||
ctx := context.Background()
|
||||
row, err := p.queries.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g := &game.Game{
|
||||
ID: row.ID,
|
||||
CurrentTurn: int(row.CurrentTurn),
|
||||
Status: game.GameStatus(row.Status),
|
||||
}
|
||||
|
||||
if err := g.BoardFromJSON(row.Board); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row.WinningCells.Valid {
|
||||
g.WinningCellsFromJSON(row.WinningCells.String)
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
g.RematchGameID = &row.RematchGameID.String
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot int) error {
|
||||
ctx := context.Background()
|
||||
|
||||
var userID, guestPlayerID sql.NullString
|
||||
if player.UserID != nil {
|
||||
userID = sql.NullString{String: *player.UserID, Valid: true}
|
||||
} else {
|
||||
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.CreateGamePlayer(ctx, repository.CreateGamePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Color),
|
||||
Slot: int64(slot),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *GamePersister) LoadGamePlayers(gameID string) ([]*game.Player, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.queries.GetGamePlayers(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
players := make([]*game.Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
player := &game.Player{
|
||||
Nickname: row.Nickname,
|
||||
Color: int(row.Color),
|
||||
}
|
||||
|
||||
if row.UserID.Valid {
|
||||
player.UserID = &row.UserID.String
|
||||
player.ID = game.PlayerID(row.UserID.String)
|
||||
} else if row.GuestPlayerID.Valid {
|
||||
player.ID = game.PlayerID(row.GuestPlayerID.String)
|
||||
}
|
||||
|
||||
players = append(players, player)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func (p *GamePersister) DeleteGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteGame(ctx, id)
|
||||
}
|
||||
|
||||
// SnakePersister implements snake.Persister
|
||||
type SnakePersister struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewSnakePersister(q *repository.Queries) *SnakePersister {
|
||||
return &SnakePersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
||||
ctx := context.Background()
|
||||
|
||||
boardJSON := "{}"
|
||||
if sg.State != nil {
|
||||
boardJSON = sg.State.ToJSON()
|
||||
}
|
||||
|
||||
var gridWidth, gridHeight sql.NullInt64
|
||||
if sg.State != nil {
|
||||
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
|
||||
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
|
||||
}
|
||||
|
||||
_, err := p.queries.GetSnakeGame(ctx, sg.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = p.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
|
||||
ID: sg.ID,
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
GridWidth: gridWidth,
|
||||
GridHeight: gridHeight,
|
||||
GameMode: int64(sg.Mode),
|
||||
SnakeSpeed: int64(sg.Speed),
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var winnerUserID sql.NullString
|
||||
if sg.Winner != nil && sg.Winner.UserID != nil {
|
||||
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
|
||||
}
|
||||
|
||||
rematchGameID := sql.NullString{}
|
||||
if sg.RematchGameID != nil {
|
||||
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.UpdateSnakeGame(ctx, repository.UpdateSnakeGameParams{
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
RematchGameID: rematchGameID,
|
||||
Score: int64(sg.Score),
|
||||
ID: sg.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
|
||||
ctx := context.Background()
|
||||
row, err := p.queries.GetSnakeGame(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := snake.GameStateFromJSON(row.Board)
|
||||
if err != nil {
|
||||
state = &snake.GameState{}
|
||||
}
|
||||
if row.GridWidth.Valid {
|
||||
state.Width = int(row.GridWidth.Int64)
|
||||
}
|
||||
if row.GridHeight.Valid {
|
||||
state.Height = int(row.GridHeight.Int64)
|
||||
}
|
||||
|
||||
sg := &snake.SnakeGame{
|
||||
ID: row.ID,
|
||||
State: state,
|
||||
Players: make([]*snake.Player, 8),
|
||||
Status: snake.Status(row.Status),
|
||||
Mode: snake.GameMode(row.GameMode),
|
||||
Score: int(row.Score),
|
||||
Speed: int(row.SnakeSpeed),
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
sg.RematchGameID = &row.RematchGameID.String
|
||||
}
|
||||
|
||||
return sg, nil
|
||||
}
|
||||
|
||||
func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) error {
|
||||
ctx := context.Background()
|
||||
|
||||
var userID, guestPlayerID sql.NullString
|
||||
if player.UserID != nil {
|
||||
userID = sql.NullString{String: *player.UserID, Valid: true}
|
||||
} else {
|
||||
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.CreateSnakePlayer(ctx, repository.CreateSnakePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Slot + 1),
|
||||
Slot: int64(player.Slot),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SnakePersister) LoadSnakePlayers(gameID string) ([]*snake.Player, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.queries.GetSnakePlayers(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
players := make([]*snake.Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
player := &snake.Player{
|
||||
Nickname: row.Nickname,
|
||||
Slot: int(row.Slot),
|
||||
}
|
||||
|
||||
if row.UserID.Valid {
|
||||
player.UserID = &row.UserID.String
|
||||
player.ID = snake.PlayerID(row.UserID.String)
|
||||
} else if row.GuestPlayerID.Valid {
|
||||
player.ID = snake.PlayerID(row.GuestPlayerID.String)
|
||||
}
|
||||
|
||||
players = append(players, player)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func (p *SnakePersister) DeleteSnakeGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteSnakeGame(ctx, id)
|
||||
}
|
||||
|
||||
type ChatPersister struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewChatPersister(q *repository.Queries) *ChatPersister {
|
||||
return &ChatPersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *ChatPersister) SaveChatMessage(gameID string, msg game.ChatMessage) error {
|
||||
return p.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
|
||||
GameID: gameID,
|
||||
Nickname: msg.Nickname,
|
||||
Color: int64(msg.Color),
|
||||
Message: msg.Message,
|
||||
CreatedAt: msg.Time,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *ChatPersister) LoadChatMessages(gameID string) ([]game.ChatMessage, error) {
|
||||
rows, err := p.queries.GetChatMessages(context.Background(), gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
// Query returns newest-first; reverse to oldest-first for display
|
||||
slices.Reverse(msgs)
|
||||
return msgs, nil
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
-- name: UpsertGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
|
||||
VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
current_turn = excluded.current_turn,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
winning_cells = excluded.winning_cells,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- name: GetGame :one
|
||||
SELECT * FROM games WHERE id = ?;
|
||||
|
||||
-- name: UpdateGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteGame :exec
|
||||
DELETE FROM games WHERE id = ?;
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||
RETURNING *;
|
||||
-- name: UpsertSnakeGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed, winner_user_id, rematch_game_id, score)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
score = excluded.score,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- name: GetSnakeGame :one
|
||||
SELECT * FROM games WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: UpdateSnakeGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: DeleteSnakeGame :exec
|
||||
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateChatMessageParams struct {
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Message string `db:"message" json:"message"`
|
||||
CreatedAt int64 `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error {
|
||||
@@ -40,13 +40,13 @@ ORDER BY created_at DESC, id DESC
|
||||
LIMIT 50
|
||||
`
|
||||
|
||||
func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMessage, error) {
|
||||
func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]*ChatMessage, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getChatMessages, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ChatMessage
|
||||
var items []*ChatMessage
|
||||
for rows.Next() {
|
||||
var i ChatMessage
|
||||
if err := rows.Scan(
|
||||
@@ -59,7 +59,7 @@ func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMes
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,63 +7,21 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createGame = `-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
|
||||
`
|
||||
|
||||
type CreateGameParams struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, createGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.CurrentTurn,
|
||||
arg.Status,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createGamePlayer = `-- name: CreateGamePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateGamePlayerParams struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
UserID *string `db:"user_id" json:"user_id"`
|
||||
GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Slot int64 `db:"slot" json:"slot"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error {
|
||||
@@ -91,13 +49,13 @@ const getActiveGames = `-- name: GetActiveGames :many
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'connect4' AND status < 2
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]*Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
var items []*Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
@@ -120,7 +78,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -135,7 +93,7 @@ const getGame = `-- name: GetGame :one
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
func (q *Queries) GetGame(ctx context.Context, id string) (*Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGame, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
@@ -156,20 +114,20 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getGamePlayers = `-- name: GetGamePlayers :many
|
||||
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
|
||||
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]*GamePlayer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GamePlayer
|
||||
var items []*GamePlayer
|
||||
for rows.Next() {
|
||||
var i GamePlayer
|
||||
if err := rows.Scan(
|
||||
@@ -183,7 +141,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -201,13 +159,13 @@ WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ([]Game, error) {
|
||||
func (q *Queries) GetGamesByUserID(ctx context.Context, userID *string) ([]*Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
var items []*Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
@@ -230,7 +188,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -257,21 +215,21 @@ ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
type GetUserActiveGamesRow struct {
|
||||
ID string
|
||||
Status int64
|
||||
CurrentTurn int64
|
||||
UpdatedAt sql.NullTime
|
||||
MyColor int64
|
||||
OpponentNickname sql.NullString
|
||||
ID string `db:"id" json:"id"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
CurrentTurn int64 `db:"current_turn" json:"current_turn"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
MyColor int64 `db:"my_color" json:"my_color"`
|
||||
OpponentNickname *string `db:"opponent_nickname" json:"opponent_nickname"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveGamesRow, error) {
|
||||
func (q *Queries) GetUserActiveGames(ctx context.Context, userID *string) ([]*GetUserActiveGamesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserActiveGamesRow
|
||||
var items []*GetUserActiveGamesRow
|
||||
for rows.Next() {
|
||||
var i GetUserActiveGamesRow
|
||||
if err := rows.Scan(
|
||||
@@ -284,7 +242,7 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -295,31 +253,38 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateGame = `-- name: UpdateGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
const upsertGame = `-- name: UpsertGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
|
||||
VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
current_turn = excluded.current_turn,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
winning_cells = excluded.winning_cells,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
type UpdateGameParams struct {
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
RematchGameID sql.NullString
|
||||
ID string
|
||||
type UpsertGameParams struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Board string `db:"board" json:"board"`
|
||||
CurrentTurn int64 `db:"current_turn" json:"current_turn"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
|
||||
WinningCells *string `db:"winning_cells" json:"winning_cells"`
|
||||
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateGame,
|
||||
func (q *Queries) UpsertGame(ctx context.Context, arg UpsertGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.CurrentTurn,
|
||||
arg.Status,
|
||||
arg.WinnerUserID,
|
||||
arg.WinningCells,
|
||||
arg.RematchGameID,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,50 +5,56 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int64
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
ID int64 `db:"id" json:"id"`
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Message string `db:"message" json:"message"`
|
||||
CreatedAt int64 `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
RematchGameID sql.NullString
|
||||
GameType string
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
MaxPlayers int64
|
||||
GameMode int64
|
||||
Score int64
|
||||
SnakeSpeed int64
|
||||
ID string `db:"id" json:"id"`
|
||||
Board string `db:"board" json:"board"`
|
||||
CurrentTurn int64 `db:"current_turn" json:"current_turn"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
|
||||
WinningCells *string `db:"winning_cells" json:"winning_cells"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
|
||||
GameType string `db:"game_type" json:"game_type"`
|
||||
GridWidth *int64 `db:"grid_width" json:"grid_width"`
|
||||
GridHeight *int64 `db:"grid_height" json:"grid_height"`
|
||||
MaxPlayers int64 `db:"max_players" json:"max_players"`
|
||||
GameMode int64 `db:"game_mode" json:"game_mode"`
|
||||
Score int64 `db:"score" json:"score"`
|
||||
SnakeSpeed int64 `db:"snake_speed" json:"snake_speed"`
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
CreatedAt sql.NullTime
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
UserID *string `db:"user_id" json:"user_id"`
|
||||
GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Slot int64 `db:"slot" json:"slot"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `db:"token" json:"token"`
|
||||
Data []byte `db:"data" json:"data"`
|
||||
Expiry float64 `db:"expiry" json:"expiry"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt sql.NullTime
|
||||
ID string `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -7,69 +7,21 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createSnakeGame = `-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
|
||||
`
|
||||
|
||||
type CreateSnakeGameParams struct {
|
||||
ID string
|
||||
Board string
|
||||
Status int64
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
GameMode int64
|
||||
SnakeSpeed int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, createSnakeGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.Status,
|
||||
arg.GridWidth,
|
||||
arg.GridHeight,
|
||||
arg.GameMode,
|
||||
arg.SnakeSpeed,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createSnakePlayer = `-- name: CreateSnakePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateSnakePlayerParams struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
UserID *string `db:"user_id" json:"user_id"`
|
||||
GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Slot int64 `db:"slot" json:"slot"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error {
|
||||
@@ -97,13 +49,13 @@ const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]*Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveSnakeGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
var items []*Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
@@ -126,7 +78,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +93,7 @@ const getSnakeGame = `-- name: GetSnakeGame :one
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? AND game_type = 'snake'
|
||||
`
|
||||
|
||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (*Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSnakeGame, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
@@ -162,20 +114,20 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getSnakePlayers = `-- name: GetSnakePlayers :many
|
||||
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? ORDER BY slot
|
||||
`
|
||||
|
||||
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
|
||||
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]*GamePlayer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GamePlayer
|
||||
var items []*GamePlayer
|
||||
for rows.Next() {
|
||||
var i GamePlayer
|
||||
if err := rows.Scan(
|
||||
@@ -189,7 +141,7 @@ func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePla
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -214,20 +166,20 @@ ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
type GetUserActiveSnakeGamesRow struct {
|
||||
ID string
|
||||
Status int64
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
UpdatedAt sql.NullTime
|
||||
ID string `db:"id" json:"id"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
GridWidth *int64 `db:"grid_width" json:"grid_width"`
|
||||
GridHeight *int64 `db:"grid_height" json:"grid_height"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveSnakeGamesRow, error) {
|
||||
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID *string) ([]*GetUserActiveSnakeGamesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserActiveSnakeGamesRow
|
||||
var items []*GetUserActiveSnakeGamesRow
|
||||
for rows.Next() {
|
||||
var i GetUserActiveSnakeGamesRow
|
||||
if err := rows.Scan(
|
||||
@@ -239,7 +191,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -250,29 +202,43 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND game_type = 'snake'
|
||||
const upsertSnakeGame = `-- name: UpsertSnakeGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed, winner_user_id, rematch_game_id, score)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
score = excluded.score,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
type UpdateSnakeGameParams struct {
|
||||
Board string
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
RematchGameID sql.NullString
|
||||
Score int64
|
||||
ID string
|
||||
type UpsertSnakeGameParams struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Board string `db:"board" json:"board"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
GridWidth *int64 `db:"grid_width" json:"grid_width"`
|
||||
GridHeight *int64 `db:"grid_height" json:"grid_height"`
|
||||
GameMode int64 `db:"game_mode" json:"game_mode"`
|
||||
SnakeSpeed int64 `db:"snake_speed" json:"snake_speed"`
|
||||
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
|
||||
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
|
||||
Score int64 `db:"score" json:"score"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateSnakeGame,
|
||||
func (q *Queries) UpsertSnakeGame(ctx context.Context, arg UpsertSnakeGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertSnakeGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.Status,
|
||||
arg.GridWidth,
|
||||
arg.GridHeight,
|
||||
arg.GameMode,
|
||||
arg.SnakeSpeed,
|
||||
arg.WinnerUserID,
|
||||
arg.RematchGameID,
|
||||
arg.Score,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ RETURNING id, username, password_hash, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
ID string `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -30,14 +30,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, created_at FROM users WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -46,14 +46,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, created_at FROM users WHERE username = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -62,5 +62,5 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
services:
|
||||
c4:
|
||||
build: .
|
||||
container_name: c4
|
||||
games:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
COMMIT: ${COMMIT:-unknown}
|
||||
container_name: games
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -11,4 +15,4 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data:/data
|
||||
|
||||
@@ -3,29 +3,26 @@ package auth
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db/repository"
|
||||
"github.com/ryanhamamura/c4/features/auth/pages"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
|
||||
"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"`
|
||||
}
|
||||
|
||||
type RegisterSignals struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -33,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)
|
||||
}
|
||||
}
|
||||
@@ -41,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 != "" {
|
||||
@@ -74,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 != "" {
|
||||
@@ -128,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)
|
||||
}
|
||||
}
|
||||
|
||||
351
features/auth/handlers_test.go
Normal file
351
features/auth/handlers_test.go
Normal 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
|
||||
}
|
||||
42
features/auth/pages/login.templ
Normal file
42
features/auth/pages/login.templ
Normal file
@@ -0,0 +1,42 @@
|
||||
package pages
|
||||
|
||||
import "github.com/ryanhamamura/games/features/common/layouts"
|
||||
|
||||
templ LoginPage(errorMsg string) {
|
||||
@layouts.Base("Login") {
|
||||
<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>
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</fieldset>
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Don't have an account? <a class="link" href="/register">Register</a>
|
||||
</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package pages
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func LoginPage() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"max-w-sm mx-auto mt-8 text-center\" data-signals=\"{username: '', password: '', error: ''}\"><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><form><fieldset class=\"fieldset\"><label class=\"label\" for=\"username\">Username</label> <input class=\"input input-bordered w-full\" id=\"username\" type=\"text\" placeholder=\"Enter your username\" data-bind=\"username\" required autofocus> <label class=\"label\" for=\"password\">Password</label> <input class=\"input input-bordered w-full\" id=\"password\" type=\"password\" placeholder=\"Enter your password\" data-bind=\"password\" required data-on:keydown.key_enter=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/auth/login"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/login.templ`, Line: 34, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></fieldset><button class=\"btn btn-primary w-full\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/auth/login"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/login.templ`, Line: 40, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">Login</button></form><p>Don't have an account? <a class=\"link\" href=\"/register\">Register</a></p></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Login").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
50
features/auth/pages/register.templ
Normal file
50
features/auth/pages/register.templ
Normal file
@@ -0,0 +1,50 @@
|
||||
package pages
|
||||
|
||||
import "github.com/ryanhamamura/games/features/common/layouts"
|
||||
|
||||
templ RegisterPage(errorMsg string) {
|
||||
@layouts.Base("Register") {
|
||||
<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>
|
||||
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"
|
||||
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)"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</fieldset>
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Already have an account? <a class="link" href="/login">Login</a>
|
||||
</p>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package pages
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/features/common/layouts"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func RegisterPage() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"max-w-sm mx-auto mt-8 text-center\" data-signals=\"{username: '', password: '', confirm: '', error: ''}\"><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><form><fieldset class=\"fieldset\"><label class=\"label\" for=\"username\">Username</label> <input class=\"input input-bordered w-full\" id=\"username\" type=\"text\" placeholder=\"Choose a username\" data-bind=\"username\" required autofocus> <label class=\"label\" for=\"password\">Password</label> <input class=\"input input-bordered w-full\" id=\"password\" type=\"password\" placeholder=\"Choose a password (min 8 chars)\" data-bind=\"password\" required> <label class=\"label\" for=\"confirm\">Confirm Password</label> <input class=\"input input-bordered w-full\" id=\"confirm\" type=\"password\" placeholder=\"Confirm your password\" data-bind=\"confirm\" required data-on:keydown.key_enter=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/auth/register"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/register.templ`, Line: 43, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></fieldset><button class=\"btn btn-primary w-full\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/auth/register"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/register.templ`, Line: 49, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">Register</button></form><p>Already have an account? <a class=\"link\" href=\"/login\">Login</a></p></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,16 +1,16 @@
|
||||
// Package auth handles user authentication routes and handlers.
|
||||
package auth
|
||||
|
||||
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) error {
|
||||
router.Get("/login", HandleLoginPage())
|
||||
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
|
||||
router.Get("/login", HandleLoginPage(sessions))
|
||||
router.Get("/register", HandleRegisterPage())
|
||||
router.Post("/api/auth/login", HandleLogin(queries, sessions))
|
||||
router.Post("/api/auth/register", HandleRegister(queries, sessions))
|
||||
|
||||
return nil
|
||||
router.Post("/auth/login", HandleLogin(queries, sessions))
|
||||
router.Post("/auth/register", HandleRegister(queries, sessions))
|
||||
}
|
||||
|
||||
65
features/c4game/components/board.templ
Normal file
65
features/c4game/components/board.templ
Normal file
@@ -0,0 +1,65 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ Board(g *connect4.Game, myColor int) {
|
||||
<div id="c4-board" class="board">
|
||||
for col := 0; col < 7; col++ {
|
||||
@column(g, col, myColor)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
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) }
|
||||
>
|
||||
for row := 0; row < 6; row++ {
|
||||
@cell(g, row, colIdx)
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="column">
|
||||
for row := 0; row < 6; row++ {
|
||||
@cell(g, row, colIdx)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ cell(g *connect4.Game, row int, col int) {
|
||||
<div class={ cellClass(g, row, col) }></div>
|
||||
}
|
||||
|
||||
func cellClass(g *connect4.Game, row, col int) string {
|
||||
color := g.Board[row][col]
|
||||
activeTurn := 0
|
||||
if g.Status == connect4.StatusInProgress {
|
||||
activeTurn = g.CurrentTurn
|
||||
}
|
||||
|
||||
class := "cell"
|
||||
switch color {
|
||||
case 1:
|
||||
class += " red"
|
||||
case 2:
|
||||
class += " yellow"
|
||||
}
|
||||
if g.IsWinningCell(row, col) {
|
||||
class += " winning"
|
||||
}
|
||||
if color != 0 && color == activeTurn {
|
||||
class += " active-turn"
|
||||
}
|
||||
return class
|
||||
}
|
||||
|
||||
// suppress unused import
|
||||
var _ = fmt.Sprintf
|
||||
@@ -1,199 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func Board(g *game.Game, myColor int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"c4-board\" class=\"board\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for col := 0; col < 7; col++ {
|
||||
templ_7745c5c3_Err = column(g, col, myColor).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func column(g *game.Game, colIdx int, myColor int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if g.Status == game.StatusInProgress && myColor == g.CurrentTurn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"column clickable\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/game/%s/drop?col=%d", g.ID, colIdx))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/board.templ`, Line: 22, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for row := 0; row < 6; row++ {
|
||||
templ_7745c5c3_Err = cell(g, row, colIdx).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"column\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for row := 0; row < 6; row++ {
|
||||
templ_7745c5c3_Err = cell(g, row, colIdx).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func cell(g *game.Game, row int, col int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var5 = []any{cellClass(g, row, col)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/board.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func cellClass(g *game.Game, row, col int) string {
|
||||
color := g.Board[row][col]
|
||||
activeTurn := 0
|
||||
if g.Status == game.StatusInProgress {
|
||||
activeTurn = g.CurrentTurn
|
||||
}
|
||||
|
||||
class := "cell"
|
||||
switch color {
|
||||
case 1:
|
||||
class += " red"
|
||||
case 2:
|
||||
class += " yellow"
|
||||
}
|
||||
if g.IsWinningCell(row, col) {
|
||||
class += " winning"
|
||||
}
|
||||
if color != 0 && color == activeTurn {
|
||||
class += " active-turn"
|
||||
}
|
||||
return class
|
||||
}
|
||||
|
||||
// suppress unused import
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,173 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
func Chat(messages []ChatMessage, gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"c4-chat\" class=\"c4-chat\"><div class=\"c4-chat-history\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, m := range messages {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"c4-chat-msg\"><span style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Color)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/chat.templ`, Line: 26, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(m.Nickname)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/chat.templ`, Line: 27, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ": </span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(m.Message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/chat.templ`, Line: 29, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = chatAutoScroll().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div><div class=\"c4-chat-input\" data-morph-ignore><input type=\"text\" placeholder=\"Chat...\" autocomplete=\"off\" data-bind=\"chatMsg\" data-on:keydown.enter=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/game/%s/chat", gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/chat.templ`, Line: 40, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"> <button type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/game/%s/chat", gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/chat.templ`, Line: 44, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">Send</button></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func chatAutoScroll() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<script>\n\t(function(){\n\t\tvar el = document.querySelector('.c4-chat-history');\n\t\tif (!el) return;\n\t\tel.scrollTop = el.scrollHeight;\n\t\tnew MutationObserver(function(){ el.scrollTop = el.scrollHeight; })\n\t\t\t.observe(el, {childList:true, subtree:true});\n\t})();\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func chatColor(color int) string {
|
||||
if c, ok := chatColors[color]; ok {
|
||||
return c
|
||||
}
|
||||
return "#666"
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
151
features/c4game/components/status.templ
Normal file
151
features/c4game/components/status.templ
Normal file
@@ -0,0 +1,151 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ StatusBanner(g *connect4.Game, myColor int) {
|
||||
<div id="c4-status" class={ statusClass(g, myColor) }>
|
||||
{ statusMessage(g, myColor) }
|
||||
if g.IsFinished() {
|
||||
if g.RematchGameID != nil {
|
||||
<a
|
||||
class="btn btn-sm bg-white text-gray-800 border-none ml-4"
|
||||
href={ templ.SafeURL("/games/" + *g.RematchGameID) }
|
||||
>
|
||||
Join Rematch
|
||||
</a>
|
||||
} else {
|
||||
<button
|
||||
class="btn btn-sm bg-white text-gray-800 border-none ml-4"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/games/%s/rematch", g.ID) }
|
||||
>
|
||||
Play again
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
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">
|
||||
<span class={ "player-chip " + info.ColorClass }></span>
|
||||
<span>{ info.Label }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ InviteLink(gameID string) {
|
||||
<div class="mt-4 text-center">
|
||||
<p>Share this link with your opponent:</p>
|
||||
<div class="bg-base-200 p-4 rounded-lg font-mono break-all my-2">
|
||||
{ config.Global.AppURL + "/games/" + gameID }
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm mt-2"
|
||||
type="button"
|
||||
onclick={ copyToClipboard(config.Global.AppURL + "/games/" + gameID) }
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
script copyToClipboard(url string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
}
|
||||
|
||||
func statusClass(g *connect4.Game, myColor int) string {
|
||||
switch g.Status {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "alert bg-base-200 text-xl font-bold"
|
||||
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 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 connect4.StatusDraw:
|
||||
return "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
return "alert bg-base-200 text-xl font-bold"
|
||||
}
|
||||
|
||||
func statusMessage(g *connect4.Game, myColor int) string {
|
||||
switch g.Status {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent..."
|
||||
case connect4.StatusInProgress:
|
||||
if g.CurrentTurn == myColor {
|
||||
return "Your turn!"
|
||||
}
|
||||
return opponentName(g, myColor) + "'s turn"
|
||||
case connect4.StatusWon:
|
||||
if g.Winner != nil && g.Winner.Color == myColor {
|
||||
return "You win!"
|
||||
}
|
||||
if g.Winner != nil {
|
||||
return g.Winner.Nickname + " wins!"
|
||||
}
|
||||
return "Game over"
|
||||
case connect4.StatusDraw:
|
||||
return "It's a draw!"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opponentName(g *connect4.Game, myColor int) string {
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.Color != myColor {
|
||||
return p.Nickname
|
||||
}
|
||||
}
|
||||
return "Opponent"
|
||||
}
|
||||
|
||||
type playerInfoData struct {
|
||||
ColorClass string
|
||||
Label string
|
||||
}
|
||||
|
||||
func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData {
|
||||
var result []playerInfoData
|
||||
|
||||
var myName, oppName string
|
||||
var myClass, oppClass string
|
||||
|
||||
for _, p := range g.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
colorClass := "yellow"
|
||||
if p.Color == 1 {
|
||||
colorClass = "red"
|
||||
}
|
||||
if p.Color == myColor {
|
||||
myName = p.Nickname
|
||||
myClass = colorClass
|
||||
} else {
|
||||
oppName = p.Nickname
|
||||
oppClass = colorClass
|
||||
}
|
||||
}
|
||||
|
||||
if oppName == "" {
|
||||
oppName = "Waiting..."
|
||||
}
|
||||
|
||||
result = append(result, playerInfoData{ColorClass: myClass, Label: myName + " (You)"})
|
||||
result = append(result, playerInfoData{ColorClass: oppClass, Label: oppName})
|
||||
return result
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/config"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func StatusBanner(g *game.Game, myColor int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var2 = []any{statusClass(g, myColor)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"c4-status\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(statusMessage(g, myColor))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 11, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if g.IsFinished() {
|
||||
if g.RematchGameID != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a class=\"btn btn-sm bg-white text-gray-800 border-none ml-4\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/game/" + *g.RematchGameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 16, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">Join Rematch</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<button class=\"btn btn-sm bg-white text-gray-800 border-none ml-4\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/game/%s/rematch", g.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 24, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">Play again</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PlayerInfo(g *game.Game, myColor int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div id=\"c4-players\" class=\"flex gap-8 mb-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, info := range playerInfoPairs(g, myColor) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 = []any{"player-chip " + info.ColorClass}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(info.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 38, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func InviteLink(gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var11 == nil {
|
||||
templ_7745c5c3_Var11 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"mt-4 text-center\"><p>Share this link with your opponent:</p><div class=\"bg-base-200 p-4 rounded-lg font-mono break-all my-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(config.Global.AppURL + "/game/" + gameID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/components/status.templ`, Line: 48, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, copyToClipboard(config.Global.AppURL+"/game/"+gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button class=\"btn btn-sm mt-2\" type=\"button\" onclick=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.ComponentScript = copyToClipboard(config.Global.AppURL + "/game/" + gameID)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13.Call)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">Copy Link</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func copyToClipboard(url string) templ.ComponentScript {
|
||||
return templ.ComponentScript{
|
||||
Name: `__templ_copyToClipboard_1463`,
|
||||
Function: `function __templ_copyToClipboard_1463(url){navigator.clipboard.writeText(url)
|
||||
}`,
|
||||
Call: templ.SafeScript(`__templ_copyToClipboard_1463`, url),
|
||||
CallInline: templ.SafeScriptInline(`__templ_copyToClipboard_1463`, url),
|
||||
}
|
||||
}
|
||||
|
||||
func statusClass(g *game.Game, myColor int) string {
|
||||
switch g.Status {
|
||||
case game.StatusWaitingForPlayer:
|
||||
return "alert bg-base-200 text-xl font-bold"
|
||||
case game.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:
|
||||
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:
|
||||
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 {
|
||||
switch g.Status {
|
||||
case game.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent..."
|
||||
case game.StatusInProgress:
|
||||
if g.CurrentTurn == myColor {
|
||||
return "Your turn!"
|
||||
}
|
||||
return opponentName(g, myColor) + "'s turn"
|
||||
case game.StatusWon:
|
||||
if g.Winner != nil && g.Winner.Color == myColor {
|
||||
return "You win!"
|
||||
}
|
||||
if g.Winner != nil {
|
||||
return g.Winner.Nickname + " wins!"
|
||||
}
|
||||
return "Game over"
|
||||
case game.StatusDraw:
|
||||
return "It's a draw!"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opponentName(g *game.Game, myColor int) string {
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.Color != myColor {
|
||||
return p.Nickname
|
||||
}
|
||||
}
|
||||
return "Opponent"
|
||||
}
|
||||
|
||||
type playerInfoData struct {
|
||||
ColorClass string
|
||||
Label string
|
||||
}
|
||||
|
||||
func playerInfoPairs(g *game.Game, myColor int) []playerInfoData {
|
||||
var result []playerInfoData
|
||||
|
||||
var myName, oppName string
|
||||
var myClass, oppClass string
|
||||
|
||||
for _, p := range g.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
colorClass := "yellow"
|
||||
if p.Color == 1 {
|
||||
colorClass = "red"
|
||||
}
|
||||
if p.Color == myColor {
|
||||
myName = p.Nickname
|
||||
myClass = colorClass
|
||||
} else {
|
||||
oppName = p.Nickname
|
||||
oppClass = colorClass
|
||||
}
|
||||
}
|
||||
|
||||
if oppName == "" {
|
||||
oppName = "Waiting..."
|
||||
}
|
||||
|
||||
result = append(result, playerInfoData{ColorClass: myClass, Label: myName + " (You)"})
|
||||
result = append(result, playerInfoData{ColorClass: oppClass, Label: oppName})
|
||||
return result
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,25 +1,25 @@
|
||||
package c4game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/features/c4game/components"
|
||||
"github.com/ryanhamamura/c4/features/c4game/pages"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
|
||||
"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, chatPersister *db.ChatPersister) 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
@@ -27,29 +27,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPer
|
||||
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)
|
||||
@@ -58,33 +49,30 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPer
|
||||
// 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()
|
||||
uiMsgs, _ := chatPersister.LoadChatMessages(gameID)
|
||||
msgs := uiChatToComponents(uiMsgs)
|
||||
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, chatPersister *db.ChatPersister) http.HandlerFunc {
|
||||
func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
ctx := r.Context()
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
@@ -92,73 +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)
|
||||
|
||||
// Load initial chat messages
|
||||
uiMsgs, _ := chatPersister.LoadChatMessages(gameID)
|
||||
var chatMu sync.Mutex
|
||||
chatMessages := uiChatToComponents(uiMsgs)
|
||||
|
||||
// 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()
|
||||
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()
|
||||
|
||||
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
|
||||
// Drain rapid-fire notifications
|
||||
drainGame:
|
||||
for {
|
||||
select {
|
||||
case <-gameCh:
|
||||
default:
|
||||
break drainGame
|
||||
}
|
||||
}
|
||||
cm := components.ChatMessage{
|
||||
Nickname: uiMsg.Nickname,
|
||||
Color: uiMsg.Color,
|
||||
Message: uiMsg.Message,
|
||||
Time: uiMsg.Time,
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
chatMu.Lock()
|
||||
chatMessages = append(chatMessages, cm)
|
||||
if len(chatMessages) > 50 {
|
||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
||||
}
|
||||
chatMu.Unlock()
|
||||
|
||||
chatMu.Lock()
|
||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
case chatMsg := <-chatCh:
|
||||
if err := sse.PatchElementTempl(
|
||||
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
||||
datastar.WithSelectorID("c4-chat-history"),
|
||||
datastar.WithModeAppend(),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
|
||||
case <-heartbeat.C:
|
||||
// Heartbeat refreshes game state to keep connection alive
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -166,9 +156,9 @@ 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
@@ -183,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)
|
||||
@@ -196,16 +181,13 @@ 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, chatPersister *db.ChatPersister) 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
@@ -227,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)
|
||||
@@ -248,30 +225,24 @@ 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(),
|
||||
}
|
||||
chatPersister.SaveChatMessage(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)
|
||||
|
||||
// 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
@@ -294,33 +265,30 @@ 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)
|
||||
sse.Redirect("/game/" + gameID) //nolint:errcheck
|
||||
sse.Redirect("/games/" + gameID) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
@@ -332,37 +300,7 @@ func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.Han
|
||||
newGI := gi.CreateRematch(store)
|
||||
sse := datastar.NewSSE(w, r)
|
||||
if newGI != nil {
|
||||
sse.Redirectf("/game/%s", newGI.ID()) //nolint:errcheck
|
||||
sse.Redirectf("/games/%s", newGI.ID()) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// uiChatToComponents converts ui.C4ChatMessage slice to components.ChatMessage slice.
|
||||
func uiChatToComponents(uiMsgs []game.ChatMessage) []components.ChatMessage {
|
||||
msgs := make([]components.ChatMessage, len(uiMsgs))
|
||||
for i, m := range uiMsgs {
|
||||
msgs[i] = components.ChatMessage{
|
||||
Nickname: m.Nickname,
|
||||
Color: m.Color,
|
||||
Message: m.Message,
|
||||
Time: m.Time,
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
57
features/c4game/pages/game.templ
Normal file
57
features/c4game/pages/game.templ
Normal file
@@ -0,0 +1,57 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"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 *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={ 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">
|
||||
@sharedcomponents.LiveClock()
|
||||
@sharedcomponents.BackToLobby()
|
||||
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
||||
@components.PlayerInfo(g, myColor)
|
||||
@components.StatusBanner(g, myColor)
|
||||
<div class="c4-game-area">
|
||||
@components.Board(g, myColor)
|
||||
@chatcomponents.Chat(messages, chatCfg)
|
||||
</div>
|
||||
if g.Status == connect4.StatusWaitingForPlayer {
|
||||
@components.InviteLink(g.ID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ JoinPage(gameID string) {
|
||||
@layouts.Base("Connect 4 - Join") {
|
||||
@sharedcomponents.GameJoinPrompt(
|
||||
"/login?return_url=/games/"+gameID,
|
||||
"/register?return_url=/games/"+gameID,
|
||||
"/games/"+gameID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
templ NicknamePage(gameID string) {
|
||||
@layouts.Base("Connect 4 - Join") {
|
||||
@sharedcomponents.NicknamePrompt("/games/" + gameID + "/join")
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package pages
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func GamePage(g *game.Game, myColor int, messages []components.ChatMessage) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"flex flex-col items-center gap-4 p-4\" data-signals=\"{chatMsg: ''}\" data-init=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.GetSSE("/game/%s/events", g.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/c4game/pages/game.templ`, Line: 16, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = sharedcomponents.BackToLobby().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = sharedcomponents.StealthTitle("text-3xl font-bold").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.PlayerInfo(g, myColor).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.StatusBanner(g, myColor).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"c4-game-area\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.Board(g, myColor).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.Chat(messages, g.ID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if g.Status == game.StatusWaitingForPlayer {
|
||||
templ_7745c5c3_Err = components.InviteLink(g.ID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Connect 4").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func JoinPage(gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = sharedcomponents.GameJoinPrompt(
|
||||
"/login?return_url=/game/"+gameID,
|
||||
"/register?return_url=/game/"+gameID,
|
||||
"/game/"+gameID,
|
||||
).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Connect 4 - Join").Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func NicknamePage(gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = sharedcomponents.NicknamePrompt("/api/game/"+gameID+"/join").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Connect 4 - Join").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,29 +1,26 @@
|
||||
// Package c4game handles Connect 4 game routes, SSE event streaming, and chat.
|
||||
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"
|
||||
"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,
|
||||
chatPersister *db.ChatPersister,
|
||||
) error {
|
||||
router.Get("/game/{game_id}", HandleGamePage(store, sessions, chatPersister))
|
||||
router.Get("/game/{game_id}/events", HandleGameEvents(store, nc, sessions, chatPersister))
|
||||
|
||||
router.Route("/api/game/{game_id}", func(r chi.Router) {
|
||||
) {
|
||||
router.Route("/games/{id}", func(r chi.Router) {
|
||||
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, chatPersister))
|
||||
r.Post("/chat", HandleSendChat(store, svc, sessions))
|
||||
r.Post("/join", HandleSetNickname(store, sessions))
|
||||
r.Post("/rematch", HandleRematch(store, sessions))
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
70
features/c4game/services/game_service.go
Normal file
70
features/c4game/services/game_service.go
Normal 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)
|
||||
}
|
||||
73
features/common/components/shared.templ
Normal file
73
features/common/components/shared.templ
Normal file
@@ -0,0 +1,73 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ BackToLobby() {
|
||||
<a class="link text-sm opacity-70" href="/">← Back</a>
|
||||
}
|
||||
|
||||
templ StealthTitle(class string) {
|
||||
<span class={ class }>
|
||||
<span style="color:#4a2a3a">●</span>
|
||||
<span style="color:#2a4545">●</span>
|
||||
<span style="color:#4a2a3a">●</span>
|
||||
<span style="color:#2a4545">●</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
templ NicknamePrompt(returnPath string) {
|
||||
<main class="max-w-sm mx-auto mt-8 text-center" data-signals="{nickname: ''}">
|
||||
<h1 class="text-3xl font-bold">Join Game</h1>
|
||||
<p class="mb-4">Enter your nickname to join the game.</p>
|
||||
<form>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="nickname">Your Nickname</label>
|
||||
<input
|
||||
class="input input-bordered w-full"
|
||||
id="nickname"
|
||||
type="text"
|
||||
placeholder="Enter your nickname"
|
||||
data-bind="nickname"
|
||||
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", returnPath) }
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</fieldset>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("%s", returnPath) }
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
<p class="mb-4">Log in to track your game history, or continue as a guest.</p>
|
||||
<div class="flex flex-col gap-2 my-4">
|
||||
<a class="btn btn-primary w-full" href={ templ.SafeURL(loginURL) }>Login</a>
|
||||
<a class="btn btn-secondary w-full" href={ templ.SafeURL(gamePath + "?guest=1") }>Continue as Guest</a>
|
||||
</div>
|
||||
<p class="text-sm opacity-60">
|
||||
Don't have an account?
|
||||
<a class="link" href={ templ.SafeURL(registerURL) }>Register</a>
|
||||
</p>
|
||||
</main>
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "github.com/starfederation/datastar-go/datastar"
|
||||
|
||||
func BackToLobby() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a class=\"link text-sm opacity-70\" href=\"/\">← Back</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func StealthTitle(class string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var3 = []any{class}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/components/shared.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><span style=\"color:#4a2a3a\">●</span> <span style=\"color:#2a4545\">●</span> <span style=\"color:#4a2a3a\">●</span> <span style=\"color:#2a4545\">●</span></span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func NicknamePrompt(returnPath string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<main class=\"max-w-sm mx-auto mt-8 text-center\" data-signals=\"{nickname: ''}\"><h1 class=\"text-3xl font-bold\">Join Game</h1><p class=\"mb-4\">Enter your nickname to join the game.</p><form><fieldset class=\"fieldset\"><label class=\"label\" for=\"nickname\">Your Nickname</label> <input class=\"input input-bordered w-full\" id=\"nickname\" type=\"text\" placeholder=\"Enter your nickname\" data-bind=\"nickname\" required autofocus></fieldset><button class=\"btn btn-primary w-full\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("%s", returnPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/components/shared.templ`, Line: 38, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">Join</button></form></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GameJoinPrompt(loginURL string, registerURL string, gamePath string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<main class=\"max-w-sm mx-auto mt-8 text-center\"><h1 class=\"text-3xl font-bold\">Join Game</h1><p class=\"mb-4\">Log in to track your game history, or continue as a guest.</p><div class=\"flex flex-col gap-2 my-4\"><a class=\"btn btn-primary w-full\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(loginURL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/components/shared.templ`, Line: 51, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">Login</a> <a class=\"btn btn-secondary w-full\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 templ.SafeURL
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(gamePath + "?guest=1"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/components/shared.templ`, Line: 52, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">Continue as Guest</a></div><p class=\"text-sm opacity-60\">Don't have an account? <a class=\"link\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 templ.SafeURL
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(registerURL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/components/shared.templ`, Line: 56, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">Register</a></p></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
28
features/common/layouts/base.templ
Normal file
28
features/common/layouts/base.templ
Normal file
@@ -0,0 +1,28 @@
|
||||
package layouts
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/games/assets"
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/version"
|
||||
)
|
||||
|
||||
templ Base(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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.StaticPath("js/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>
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package layouts
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "github.com/ryanhamamura/c4/config"
|
||||
|
||||
func Base(title string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/common/layouts/base.templ`, Line: 9, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0\"><script defer type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@starfederation/datastar@1.0.0-beta.11/dist/datastar.min.js\"></script><link href=\"/assets/css/output.css\" rel=\"stylesheet\" type=\"text/css\"></head><body class=\"flex flex-col h-screen\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if config.Global.Environment == config.Dev {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div data-init=\"@get('/reload', {retryMaxCount: 1000, retryInterval:20, retryMaxWaitMs:200})\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
109
features/lobby/components/gamelist.templ
Normal file
109
features/lobby/components/gamelist.templ
Normal file
@@ -0,0 +1,109 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ GameList(games []GameListItem) {
|
||||
if len(games) > 0 {
|
||||
<div class="mt-8 text-left">
|
||||
<h3 class="mb-4 text-center text-lg font-bold">Your Games</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, g := range games {
|
||||
@gameListEntry(g)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ gameListEntry(g GameListItem) {
|
||||
<div class="flex items-center gap-2 p-2 bg-base-200 rounded-lg transition-colors hover:bg-base-300">
|
||||
<a
|
||||
href={ templ.SafeURL("/games/" + g.ID) }
|
||||
class="flex-1 flex justify-between items-center px-2 py-1 no-underline text-base-content"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-bold">{ opponentDisplay(g) }</span>
|
||||
<span class={ statusClass(g) }>{ statusText(g) }</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs opacity-60">{ formatTimeAgo(g.LastPlayed) }</span>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-square hover:btn-error"
|
||||
data-on:click={ datastar.DeleteSSE("/games/%s", g.ID) }
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
func statusText(g GameListItem) string {
|
||||
switch connect4.Status(g.Status) {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent"
|
||||
case connect4.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "Your turn!"
|
||||
}
|
||||
return "Opponent's turn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func statusClass(g GameListItem) string {
|
||||
switch connect4.Status(g.Status) {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "text-sm opacity-60"
|
||||
case connect4.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "text-sm text-success font-bold"
|
||||
}
|
||||
return "text-sm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opponentDisplay(g GameListItem) string {
|
||||
if g.OpponentName == "" {
|
||||
return "Waiting for opponent..."
|
||||
}
|
||||
return "vs " + g.OpponentName
|
||||
}
|
||||
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if duration < time.Hour {
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", mins)
|
||||
}
|
||||
if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "yesterday"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func GameList(games []GameListItem) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if len(games) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"mt-8 text-left\"><h3 class=\"mb-4 text-center text-lg font-bold\">Your Games</h3><div class=\"flex flex-col gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, g := range games {
|
||||
templ_7745c5c3_Err = gameListEntry(g).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func gameListEntry(g GameListItem) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"flex items-center gap-2 p-2 bg-base-200 rounded-lg transition-colors hover:bg-base-300\"><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 templ.SafeURL
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/game/" + g.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/components/gamelist.templ`, Line: 27, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"flex-1 flex justify-between items-center px-2 py-1 no-underline text-base-content\"><div class=\"flex flex-col gap-1\"><span class=\"font-bold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(opponentDisplay(g))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/components/gamelist.templ`, Line: 31, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 = []any{statusClass(g)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/components/gamelist.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(statusText(g))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/components/gamelist.templ`, Line: 32, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></div><div><span class=\"text-xs opacity-60\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(formatTimeAgo(g.LastPlayed))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/components/gamelist.templ`, Line: 35, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span></div></a> <button type=\"button\" class=\"btn btn-ghost btn-sm btn-square hover:btn-error\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.DeleteSSE("/api/lobby/game/%s", g.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/components/gamelist.templ`, Line: 41, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">×</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func statusText(g GameListItem) string {
|
||||
switch game.GameStatus(g.Status) {
|
||||
case game.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent"
|
||||
case game.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "Your turn!"
|
||||
}
|
||||
return "Opponent's turn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func statusClass(g GameListItem) string {
|
||||
switch game.GameStatus(g.Status) {
|
||||
case game.StatusWaitingForPlayer:
|
||||
return "text-sm opacity-60"
|
||||
case game.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "text-sm text-success font-bold"
|
||||
}
|
||||
return "text-sm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opponentDisplay(g GameListItem) string {
|
||||
if g.OpponentName == "" {
|
||||
return "Waiting for opponent..."
|
||||
}
|
||||
return "vs " + g.OpponentName
|
||||
}
|
||||
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if duration < time.Hour {
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", mins)
|
||||
}
|
||||
if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "yesterday"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -2,16 +2,17 @@ package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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,23 +22,31 @@ 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 != ""
|
||||
|
||||
var userGames []lobbycomponents.GameListItem
|
||||
if isLoggedIn {
|
||||
ctx := context.Background()
|
||||
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true})
|
||||
games, err := queries.GetUserActiveGames(ctx, &userID)
|
||||
if err == nil {
|
||||
for _, g := range games {
|
||||
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
|
||||
opponentName := ""
|
||||
if g.OpponentNickname != nil {
|
||||
opponentName = *g.OpponentNickname
|
||||
}
|
||||
var lastPlayed time.Time
|
||||
if g.UpdatedAt != nil {
|
||||
lastPlayed = *g.UpdatedAt
|
||||
}
|
||||
userGames = append(userGames, lobbycomponents.GameListItem{
|
||||
ID: g.ID,
|
||||
Status: int(g.Status),
|
||||
OpponentName: g.OpponentNickname.String,
|
||||
OpponentName: opponentName,
|
||||
IsMyTurn: isMyTurn,
|
||||
LastPlayed: g.UpdatedAt.Time,
|
||||
LastPlayed: lastPlayed,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,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"`
|
||||
@@ -87,16 +96,16 @@ 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)
|
||||
sse.ExecuteScript(fmt.Sprintf("window.location.href='/game/%s'", gi.ID()))
|
||||
sse.ExecuteScript(fmt.Sprintf("window.location.href='/games/%s'", gi.ID())) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
@@ -104,10 +113,10 @@ func HandleDeleteGame(store *game.GameStore, sessions *scs.SessionManager) http.
|
||||
return
|
||||
}
|
||||
|
||||
store.Delete(gameID)
|
||||
store.Delete(gameID) //nolint:errcheck
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.ExecuteScript("window.location.href='/'")
|
||||
sse.ExecuteScript("window.location.href='/'") //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,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" {
|
||||
@@ -150,7 +159,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa
|
||||
si := snakeStore.Create(preset.Width, preset.Height, mode, speed)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.ExecuteScript(fmt.Sprintf("window.location.href='/snake/%s'", si.ID()))
|
||||
sse.ExecuteScript(fmt.Sprintf("window.location.href='/snake/%s'", si.ID())) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +171,6 @@ func HandleLogout(sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.ExecuteScript("window.location.href='/'")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
169
features/lobby/pages/lobby.templ
Normal file
169
features/lobby/pages/lobby.templ
Normal file
@@ -0,0 +1,169 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
templ LobbyPage(data LobbyData) {
|
||||
@layouts.Base("Game Lobby") {
|
||||
<main
|
||||
class="max-w-md mx-auto mt-8 text-center"
|
||||
data-signals="{activeTab: 'connect4', nickname: '', selectedSpeed: 1}"
|
||||
>
|
||||
// Auth header
|
||||
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>
|
||||
<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">
|
||||
Playing as guest.
|
||||
<a class="link" href="/login">Login</a>
|
||||
or
|
||||
<a class="link" href="/register">Register</a>
|
||||
to save your games.
|
||||
</div>
|
||||
}
|
||||
// Title
|
||||
<h1 class="text-3xl font-bold mb-4">
|
||||
@components.StealthTitle("")
|
||||
</h1>
|
||||
// Tab buttons
|
||||
<div class="tabs tabs-box mb-6 justify-center">
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
data-class="{'tab-active': $activeTab==='connect4'}"
|
||||
data-on:click="$activeTab='connect4'"
|
||||
>
|
||||
@components.StealthTitle("")
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
type="button"
|
||||
data-class="{'tab-active': $activeTab==='snake'}"
|
||||
data-on:click="$activeTab='snake'"
|
||||
>
|
||||
~~~~
|
||||
</button>
|
||||
</div>
|
||||
// Connect4 tab
|
||||
<div data-show="$activeTab==='connect4'">
|
||||
<p class="mb-4">Start a new session</p>
|
||||
<form>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="nickname">Your Nickname</label>
|
||||
<input
|
||||
class="input input-bordered w-full"
|
||||
id="nickname"
|
||||
type="text"
|
||||
placeholder="Enter your nickname"
|
||||
data-bind="nickname"
|
||||
required
|
||||
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("/games") }
|
||||
/>
|
||||
</fieldset>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/games") }
|
||||
>
|
||||
Create Game
|
||||
</button>
|
||||
</form>
|
||||
@lobbycomponents.GameList(data.UserGames)
|
||||
</div>
|
||||
// Snake tab
|
||||
<div data-show="$activeTab==='snake'">
|
||||
// Nickname
|
||||
<div class="mb-4">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="snake-nickname">Your Nickname</label>
|
||||
<input
|
||||
class="input input-bordered w-full"
|
||||
id="snake-nickname"
|
||||
type="text"
|
||||
placeholder="Enter your nickname"
|
||||
data-bind="nickname"
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
// Speed selector
|
||||
<div class="mb-4">
|
||||
<label class="label">Speed</label>
|
||||
<div class="btn-group">
|
||||
for i, preset := range snake.SpeedPresets {
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
data-class={ fmt.Sprintf("{'btn-active': $selectedSpeed===%d}", i) }
|
||||
data-on:click={ fmt.Sprintf("$selectedSpeed=%d", i) }
|
||||
>
|
||||
{ preset.Name }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
// Solo play
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold mb-2">Play Solo</h3>
|
||||
<div class="flex gap-2 justify-center flex-wrap">
|
||||
for i, preset := range snake.GridPresets {
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/snake?mode=solo&preset=%d", i) }
|
||||
>
|
||||
{ fmt.Sprintf("%s (%d\u00d7%d)", preset.Name, preset.Width, preset.Height) }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
// Multiplayer
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold mb-2">Create Multiplayer Game</h3>
|
||||
<div class="flex gap-2 justify-center flex-wrap">
|
||||
for i, preset := range snake.GridPresets {
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/snake?mode=multi&preset=%d", i) }
|
||||
>
|
||||
{ fmt.Sprintf("%s (%d\u00d7%d)", preset.Name, preset.Width, preset.Height) }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
// Active snake games
|
||||
if len(data.ActiveSnakeGames) > 0 {
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-bold mb-2 text-center">Join a Game</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, g := range data.ActiveSnakeGames {
|
||||
<a
|
||||
href={ templ.SafeURL("/snake/" + g.ID) }
|
||||
class="flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content"
|
||||
>
|
||||
<span>{ fmt.Sprintf("%d\u00d7%d \u2014 %d/8 players", g.Width, g.Height, g.PlayerCount) }</span>
|
||||
<span class="text-sm opacity-60">{ g.StatusLabel }</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package pages
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
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/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func LobbyPage(data LobbyData) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"max-w-md mx-auto mt-8 text-center\" data-signals=\"{activeTab: 'connect4', nickname: '', selectedSpeed: 1}\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if data.IsLoggedIn {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"flex justify-center items-center gap-4 mb-4 p-2 bg-base-200 rounded-lg\"><span>Logged in as <strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 22, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</strong></span> <button type=\"button\" class=\"btn btn-ghost btn-sm\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/lobby/logout"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 26, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">Logout</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"alert text-sm mb-4\">Playing as guest. <a class=\"link\" href=\"/login\">Login</a> or <a class=\"link\" href=\"/register\">Register</a> to save your games.</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h1 class=\"text-3xl font-bold mb-4\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.StealthTitle("").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h1><div class=\"tabs tabs-box mb-6 justify-center\"><button class=\"tab\" type=\"button\" data-class=\"{tab-active: $activeTab==='connect4'}\" data-on:click=\"$activeTab='connect4'\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.StealthTitle("").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</button> <button class=\"tab\" type=\"button\" data-class=\"{tab-active: $activeTab==='snake'}\" data-on:click=\"$activeTab='snake'\">~~~~</button></div><div data-show=\"$activeTab==='connect4'\"><p class=\"mb-4\">Start a new session</p><form><fieldset class=\"fieldset\"><label class=\"label\" for=\"nickname\">Your Nickname</label> <input class=\"input input-bordered w-full\" id=\"nickname\" type=\"text\" placeholder=\"Enter your nickname\" data-bind=\"nickname\" required data-on:keydown.enter=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/lobby/create-game"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 76, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></fieldset><button class=\"btn btn-primary w-full\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/lobby/create-game"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 82, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">Create Game</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = lobbycomponents.GameList(data.UserGames).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div data-show=\"$activeTab==='snake'\"><div class=\"mb-4\"><fieldset class=\"fieldset\"><label class=\"label\" for=\"snake-nickname\">Your Nickname</label> <input class=\"input input-bordered w-full\" id=\"snake-nickname\" type=\"text\" placeholder=\"Enter your nickname\" data-bind=\"nickname\" required></fieldset></div><div class=\"mb-4\"><label class=\"label\">Speed</label><div class=\"btn-group\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, preset := range snake.SpeedPresets {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button class=\"btn btn-sm\" type=\"button\" data-class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("{btn-active: $selectedSpeed===%d}", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 113, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("$selectedSpeed=%d", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 114, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(preset.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 116, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div><div class=\"mb-6\"><h3 class=\"text-lg font-bold mb-2\">Play Solo</h3><div class=\"flex gap-2 justify-center flex-wrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, preset := range snake.GridPresets {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<button class=\"btn btn-secondary\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/lobby/create-snake?mode=solo&preset=%d", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 129, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s (%d\u00d7%d)", preset.Name, preset.Width, preset.Height))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 131, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div><div class=\"mb-6\"><h3 class=\"text-lg font-bold mb-2\">Create Multiplayer Game</h3><div class=\"flex gap-2 justify-center flex-wrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, preset := range snake.GridPresets {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button class=\"btn btn-primary\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/lobby/create-snake?mode=multi&preset=%d", i))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 144, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s (%d\u00d7%d)", preset.Name, preset.Width, preset.Height))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 146, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(data.ActiveSnakeGames) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"mt-6\"><h3 class=\"text-lg font-bold mb-2 text-center\">Join a Game</h3><div class=\"flex flex-col gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, g := range data.ActiveSnakeGames {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/snake/" + g.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 158, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content\"><span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d\u00d7%d \u2014 %d/8 players", g.Width, g.Height, g.PlayerCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 161, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span> <span class=\"text-sm opacity-60\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(g.StatusLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/lobby/pages/lobby.templ`, Line: 162, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Game Lobby").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Package lobby handles the game lobby page, game creation, and navigation.
|
||||
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"
|
||||
@@ -13,17 +14,13 @@ func SetupRoutes(
|
||||
router chi.Router,
|
||||
queries *repository.Queries,
|
||||
sessions *scs.SessionManager,
|
||||
store *game.GameStore,
|
||||
store *connect4.Store,
|
||||
snakeStore *snake.SnakeStore,
|
||||
) error {
|
||||
) {
|
||||
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))
|
||||
|
||||
router.Route("/api/lobby", func(r chi.Router) {
|
||||
r.Post("/create-game", HandleCreateGame(store, sessions))
|
||||
r.Delete("/game/{id}", HandleDeleteGame(store, sessions))
|
||||
r.Post("/create-snake", HandleCreateSnakeGame(snakeStore, sessions))
|
||||
r.Post("/logout", HandleLogout(sessions))
|
||||
})
|
||||
|
||||
return nil
|
||||
router.Post("/games", HandleCreateGame(store, sessions))
|
||||
router.Delete("/games/{id}", HandleDeleteGame(store, sessions))
|
||||
router.Post("/snake", HandleCreateSnakeGame(snakeStore, sessions))
|
||||
router.Post("/logout", HandleLogout(sessions))
|
||||
}
|
||||
|
||||
113
features/snakegame/components/board.templ
Normal file
113
features/snakegame/components/board.templ
Normal file
@@ -0,0 +1,113 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
)
|
||||
|
||||
func cellSizeForGrid(width, height int) int {
|
||||
maxDim := width
|
||||
if height > maxDim {
|
||||
maxDim = height
|
||||
}
|
||||
switch {
|
||||
case maxDim <= 15:
|
||||
return 28
|
||||
case maxDim <= 20:
|
||||
return 24
|
||||
case maxDim <= 30:
|
||||
return 20
|
||||
case maxDim <= 40:
|
||||
return 16
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
|
||||
templ Board(sg *snake.SnakeGame) {
|
||||
<div
|
||||
id="snake-board"
|
||||
class="snake-board"
|
||||
if sg.State != nil && (sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished) {
|
||||
style={ fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", sg.State.Width) }
|
||||
}
|
||||
>
|
||||
if sg.State != nil && (sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished) {
|
||||
@boardCells(sg)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ boardCells(sg *snake.SnakeGame) {
|
||||
{{ state := sg.State }}
|
||||
{{ grid := buildGrid(state) }}
|
||||
{{ cellSize := cellSizeForGrid(state.Width, state.Height) }}
|
||||
for y := 0; y < state.Height; y++ {
|
||||
<div class="snake-row">
|
||||
for x := 0; x < state.Width; x++ {
|
||||
{{ ci := grid[y][x] }}
|
||||
if ci.snakeIdx == -2 {
|
||||
<div class="snake-cell snake-food" style={ fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize) }></div>
|
||||
} else if ci.snakeIdx >= 0 {
|
||||
{{ s := state.Snakes[ci.snakeIdx] }}
|
||||
{{ bg := snakeColor(ci.snakeIdx) }}
|
||||
if ci.isHead {
|
||||
if s.Alive {
|
||||
<div class="snake-cell snake-head" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;box-shadow:0 0 8px %s;", cellSize, cellSize, bg, bg) }></div>
|
||||
} else {
|
||||
<div class="snake-cell snake-head snake-dead" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;box-shadow:0 0 8px %s;", cellSize, cellSize, bg, bg) }></div>
|
||||
}
|
||||
} else {
|
||||
if s.Alive {
|
||||
<div class="snake-cell snake-body" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;", cellSize, cellSize, bg) }></div>
|
||||
} else {
|
||||
<div class="snake-cell snake-body snake-dead" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;", cellSize, cellSize, bg) }></div>
|
||||
}
|
||||
}
|
||||
} else {
|
||||
<div class="snake-cell" style={ fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize) }></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
func buildGrid(state *snake.GameState) [][]cellInfo {
|
||||
grid := make([][]cellInfo, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
grid[y] = make([]cellInfo, state.Width)
|
||||
for x := 0; x < state.Width; x++ {
|
||||
grid[y][x] = cellInfo{snakeIdx: -1}
|
||||
}
|
||||
}
|
||||
for fi := range state.Food {
|
||||
f := state.Food[fi]
|
||||
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
|
||||
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
|
||||
}
|
||||
}
|
||||
for si, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for bi, bp := range s.Body {
|
||||
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
|
||||
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
func snakeColor(idx int) string {
|
||||
if idx >= 0 && idx < len(snake.SnakeColors) {
|
||||
return snake.SnakeColors[idx]
|
||||
}
|
||||
return "#666"
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
)
|
||||
|
||||
func cellSizeForGrid(width, height int) int {
|
||||
maxDim := width
|
||||
if height > maxDim {
|
||||
maxDim = height
|
||||
}
|
||||
switch {
|
||||
case maxDim <= 15:
|
||||
return 28
|
||||
case maxDim <= 20:
|
||||
return 24
|
||||
case maxDim <= 30:
|
||||
return 20
|
||||
case maxDim <= 40:
|
||||
return 16
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
|
||||
func Board(sg *snake.SnakeGame) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"snake-board\" class=\"snake-board\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sg.State != nil && (sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", sg.State.Width))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 38, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sg.State != nil && (sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished) {
|
||||
templ_7745c5c3_Err = boardCells(sg).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func boardCells(sg *snake.SnakeGame) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var3 == nil {
|
||||
templ_7745c5c3_Var3 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
state := sg.State
|
||||
grid := buildGrid(state)
|
||||
cellSize := cellSizeForGrid(state.Width, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"snake-row\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for x := 0; x < state.Width; x++ {
|
||||
ci := grid[y][x]
|
||||
if ci.snakeIdx == -2 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"snake-cell snake-food\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 56, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if ci.snakeIdx >= 0 {
|
||||
s := state.Snakes[ci.snakeIdx]
|
||||
bg := snakeColor(ci.snakeIdx)
|
||||
if ci.isHead {
|
||||
if s.Alive {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"snake-cell snake-head\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:%dpx;height:%dpx;background:%s;box-shadow:0 0 8px %s;", cellSize, cellSize, bg, bg))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 62, Col: 152}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"snake-cell snake-head snake-dead\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:%dpx;height:%dpx;background:%s;box-shadow:0 0 8px %s;", cellSize, cellSize, bg, bg))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 64, Col: 163}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if s.Alive {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"snake-cell snake-body\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:%dpx;height:%dpx;background:%s;", cellSize, cellSize, bg))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 68, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"snake-cell snake-body snake-dead\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:%dpx;height:%dpx;background:%s;", cellSize, cellSize, bg))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 70, Col: 137}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"snake-cell\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/board.templ`, Line: 74, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func buildGrid(state *snake.GameState) [][]cellInfo {
|
||||
grid := make([][]cellInfo, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
grid[y] = make([]cellInfo, state.Width)
|
||||
for x := 0; x < state.Width; x++ {
|
||||
grid[y][x] = cellInfo{snakeIdx: -1}
|
||||
}
|
||||
}
|
||||
for fi := range state.Food {
|
||||
f := state.Food[fi]
|
||||
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
|
||||
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
|
||||
}
|
||||
}
|
||||
for si, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for bi, bp := range s.Body {
|
||||
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
|
||||
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
func snakeColor(idx int) string {
|
||||
if idx >= 0 && idx < len(snake.SnakeColors) {
|
||||
return snake.SnakeColors[idx]
|
||||
}
|
||||
return "#666"
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,173 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func Chat(messages []ChatMessage, gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"snake-chat\" class=\"snake-chat\"><div class=\"snake-chat-history\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, m := range messages {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"snake-chat-msg\"><span style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("color:%s;font-weight:bold;", chatColor(m.Slot)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/chat.templ`, Line: 22, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(m.Nickname + ": ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/chat.templ`, Line: 23, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(m.Message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/chat.templ`, Line: 25, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/snake/%s/chat", gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/chat.templ`, Line: 36, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"> <button type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/snake/%s/chat", gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/chat.templ`, Line: 40, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">Send</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = chatAutoScroll().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func chatAutoScroll() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<script>\n\t(function(){\n\t\tvar el = document.querySelector('.snake-chat-history');\n\t\tif (!el) return;\n\t\tel.scrollTop = el.scrollHeight;\n\t\tnew MutationObserver(function(){ el.scrollTop = el.scrollHeight; })\n\t\t\t.observe(el, {childList:true, subtree:true});\n\t})();\n\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func chatColor(slot int) string {
|
||||
if slot >= 0 && slot < len(snake.SnakeColors) {
|
||||
return snake.SnakeColors[slot]
|
||||
}
|
||||
return "#666"
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
137
features/snakegame/components/status.templ
Normal file
137
features/snakegame/components/status.templ
Normal file
@@ -0,0 +1,137 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ StatusBanner(sg *snake.SnakeGame, mySlot int, gameID string) {
|
||||
<div id="snake-status">
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
<div class="alert bg-base-200 text-xl font-bold">Ready?</div>
|
||||
} else {
|
||||
<div class="alert bg-base-200 text-xl font-bold">Waiting for players...</div>
|
||||
}
|
||||
case snake.StatusCountdown:
|
||||
{{ remaining := time.Until(sg.CountdownEnd) }}
|
||||
{{ secs := int(math.Ceil(remaining.Seconds())) }}
|
||||
if secs < 0 {
|
||||
{{ secs = 0 }}
|
||||
}
|
||||
<div class="alert alert-info text-xl font-bold">
|
||||
{ fmt.Sprintf("Starting in %d...", secs) }
|
||||
</div>
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) && sg.State.Snakes[mySlot] != nil && !sg.State.Snakes[mySlot].Alive {
|
||||
<div class="alert alert-error text-xl font-bold">You're out!</div>
|
||||
} else if sg.Mode == snake.ModeSinglePlayer {
|
||||
<div class="alert alert-success text-xl font-bold">
|
||||
{ fmt.Sprintf("Score: %d", sg.Score) }
|
||||
</div>
|
||||
} else {
|
||||
<div class="alert alert-success text-xl font-bold">Go!</div>
|
||||
}
|
||||
case snake.StatusFinished:
|
||||
@finishedBanner(sg, mySlot, gameID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ finishedBanner(sg *snake.SnakeGame, mySlot int, gameID string) {
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
<div class="alert alert-info text-xl font-bold">
|
||||
{ fmt.Sprintf("Game Over! Score: %d", sg.Score) }
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
} else if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
<div class="alert alert-success text-xl font-bold">
|
||||
You win!
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
} else {
|
||||
<div class="alert alert-error text-xl font-bold">
|
||||
{ sg.Winner.Nickname + " wins!" }
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="alert alert-warning text-xl font-bold">
|
||||
It's a draw!
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ rematchOrJoin(sg *snake.SnakeGame, gameID string) {
|
||||
if sg.RematchGameID != nil {
|
||||
<a class="btn btn-sm bg-white text-gray-800 border-none ml-4" href={ templ.SafeURL("/snake/" + *sg.RematchGameID) }>
|
||||
Join Rematch
|
||||
</a>
|
||||
} else {
|
||||
<button
|
||||
class="btn btn-sm bg-white text-gray-800 border-none ml-4"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/snake/%s/rematch", gameID) }
|
||||
>
|
||||
Play again
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
templ PlayerList(sg *snake.SnakeGame, mySlot int) {
|
||||
<div id="snake-players" class="flex flex-wrap gap-4 mb-2">
|
||||
for i, p := range sg.Players {
|
||||
if p != nil {
|
||||
<div class="flex items-center gap-2">
|
||||
<span style={ fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", snakeColor(i)) }></span>
|
||||
<span>
|
||||
{ p.Nickname }
|
||||
if i == mySlot {
|
||||
{ " (You)" }
|
||||
}
|
||||
</span>
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) && sg.State.Snakes[i] != nil {
|
||||
if sg.State.Snakes[i].Alive {
|
||||
<span class="text-sm opacity-60">
|
||||
{ fmt.Sprintf("(%d)", len(sg.State.Snakes[i].Body)) }
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-sm opacity-40">(dead)</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ InviteLink(gameID string) {
|
||||
{{ fullURL := config.Global.AppURL + "/snake/" + gameID }}
|
||||
<div id="snake-invite" class="mt-4 text-center">
|
||||
<p>Share this link to invite players:</p>
|
||||
<div class="bg-base-200 p-4 rounded-lg font-mono break-all my-2">
|
||||
{ fullURL }
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm mt-2"
|
||||
type="button"
|
||||
onclick={ copyToClipboard(fullURL) }
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
script copyToClipboard(url string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package components
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/config"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func StatusBanner(sg *snake.SnakeGame, mySlot int, gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"snake-status\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"alert bg-base-200 text-xl font-bold\">Ready?</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"alert bg-base-200 text-xl font-bold\">Waiting for players...</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
case snake.StatusCountdown:
|
||||
remaining := time.Until(sg.CountdownEnd)
|
||||
secs := int(math.Ceil(remaining.Seconds()))
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " <div class=\"alert alert-info text-xl font-bold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Starting in %d...", secs))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 29, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) && sg.State.Snakes[mySlot] != nil && !sg.State.Snakes[mySlot].Alive {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"alert alert-error text-xl font-bold\">You're out!</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if sg.Mode == snake.ModeSinglePlayer {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"alert alert-success text-xl font-bold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Score: %d", sg.Score))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 36, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"alert alert-success text-xl font-bold\">Go!</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
case snake.StatusFinished:
|
||||
templ_7745c5c3_Err = finishedBanner(sg, mySlot, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func finishedBanner(sg *snake.SnakeGame, mySlot int, gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var4 == nil {
|
||||
templ_7745c5c3_Var4 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"alert alert-info text-xl font-bold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Game Over! Score: %d", sg.Score))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 50, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = rematchOrJoin(sg, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"alert alert-success text-xl font-bold\">You win!")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = rematchOrJoin(sg, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"alert alert-error text-xl font-bold\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(sg.Winner.Nickname + " wins!")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 61, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = rematchOrJoin(sg, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"alert alert-warning text-xl font-bold\">It's a draw!")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = rematchOrJoin(sg, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func rematchOrJoin(sg *snake.SnakeGame, gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var7 == nil {
|
||||
templ_7745c5c3_Var7 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if sg.RematchGameID != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a class=\"btn btn-sm bg-white text-gray-800 border-none ml-4\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/snake/" + *sg.RematchGameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 75, Col: 115}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">Join Rematch</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button class=\"btn btn-sm bg-white text-gray-800 border-none ml-4\" type=\"button\" data-on:click=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.PostSSE("/api/snake/%s/rematch", gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 82, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">Play again</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PlayerList(sg *snake.SnakeGame, mySlot int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var10 == nil {
|
||||
templ_7745c5c3_Var10 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div id=\"snake-players\" class=\"flex flex-wrap gap-4 mb-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, p := range sg.Players {
|
||||
if p != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"flex items-center gap-2\"><span style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", snakeColor(i)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 94, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"></span> <span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(p.Nickname)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 96, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if i == mySlot {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" (You)")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 98, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) && sg.State.Snakes[i] != nil {
|
||||
if sg.State.Snakes[i].Alive {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<span class=\"text-sm opacity-60\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("(%d)", len(sg.State.Snakes[i].Body)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 105, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"text-sm opacity-40\">(dead)</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func InviteLink(gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var15 == nil {
|
||||
templ_7745c5c3_Var15 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
fullURL := config.Global.AppURL + "/snake/" + gameID
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div id=\"snake-invite\" class=\"mt-4 text-center\"><p>Share this link to invite players:</p><div class=\"bg-base-200 p-4 rounded-lg font-mono break-all my-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fullURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/components/status.templ`, Line: 123, Col: 12}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, copyToClipboard(fullURL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<button class=\"btn btn-sm mt-2\" type=\"button\" onclick=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.ComponentScript = copyToClipboard(fullURL)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17.Call)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">Copy Link</button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func copyToClipboard(url string) templ.ComponentScript {
|
||||
return templ.ComponentScript{
|
||||
Name: `__templ_copyToClipboard_1463`,
|
||||
Function: `function __templ_copyToClipboard_1463(url){navigator.clipboard.writeText(url)
|
||||
}`,
|
||||
Call: templ.SafeScript(`__templ_copyToClipboard_1463`, url),
|
||||
CallInline: templ.SafeScriptInline(`__templ_copyToClipboard_1463`, url),
|
||||
}
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,63 +1,51 @@
|
||||
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/ryanhamamura/c4/features/snakegame/components"
|
||||
"github.com/ryanhamamura/c4/features/snakegame/pages"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
|
||||
"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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
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 {
|
||||
@@ -72,59 +60,78 @@ 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := getPlayerID(sessions, r)
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
|
||||
// Send initial render
|
||||
sg := si.GetGame()
|
||||
sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck
|
||||
sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck
|
||||
sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck
|
||||
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
|
||||
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to game updates via NATS
|
||||
gameCh := make(chan *nats.Msg, 64)
|
||||
gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh)
|
||||
// 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()
|
||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||
|
||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||
))
|
||||
|
||||
chatCfg := svc.ChatConfig(gameID)
|
||||
|
||||
// Chat room (multiplayer only)
|
||||
var room *chat.Room
|
||||
sg := si.GetGame()
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
room = svc.ChatRoom(gameID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
if room != nil {
|
||||
chatCh, cleanupChat = room.Subscribe()
|
||||
defer cleanupChat()
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
@@ -133,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 {
|
||||
@@ -143,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
|
||||
}
|
||||
}
|
||||
@@ -184,16 +177,16 @@ 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
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)
|
||||
@@ -216,9 +209,9 @@ 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
@@ -235,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)
|
||||
@@ -243,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
|
||||
@@ -263,9 +254,9 @@ 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
@@ -282,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)
|
||||
@@ -303,9 +294,9 @@ 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, "game_id")
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
|
||||
84
features/snakegame/pages/game.templ
Normal file
84
features/snakegame/pages/game.templ
Normal file
@@ -0,0 +1,84 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// keydownScript builds the inline JS for a single data-on:keydown handler
|
||||
// that dispatches WASD/arrow keys to direction POST endpoints.
|
||||
func keydownScript(gameID string) string {
|
||||
return fmt.Sprintf(
|
||||
"const k=evt.key;"+
|
||||
"if(k==='w'||k==='ArrowUp'){evt.preventDefault();%s}"+
|
||||
"else if(k==='s'||k==='ArrowDown'){evt.preventDefault();%s}"+
|
||||
"else if(k==='a'||k==='ArrowLeft'){evt.preventDefault();%s}"+
|
||||
"else if(k==='d'||k==='ArrowRight'){evt.preventDefault();%s}",
|
||||
datastar.PostSSE("/snake/%s/dir?d=0", gameID),
|
||||
datastar.PostSSE("/snake/%s/dir?d=1", gameID),
|
||||
datastar.PostSSE("/snake/%s/dir?d=2", gameID),
|
||||
datastar.PostSSE("/snake/%s/dir?d=3", gameID),
|
||||
)
|
||||
}
|
||||
|
||||
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={ 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">
|
||||
@components.LiveClock()
|
||||
@components.BackToLobby()
|
||||
<h1 class="text-3xl font-bold">~~~~</h1>
|
||||
@snakecomponents.PlayerList(sg, mySlot)
|
||||
@snakecomponents.StatusBanner(sg, mySlot, gameID)
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
<div class="snake-game-area">
|
||||
@snakecomponents.Board(sg)
|
||||
@chatcomponents.Chat(messages, chatCfg)
|
||||
</div>
|
||||
} else {
|
||||
@snakecomponents.Board(sg)
|
||||
}
|
||||
} else if sg.Mode == snake.ModeMultiplayer {
|
||||
@chatcomponents.Chat(messages, chatCfg)
|
||||
}
|
||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||
@snakecomponents.InviteLink(gameID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ JoinPage(gameID string) {
|
||||
@layouts.Base("Snake - Join") {
|
||||
@components.GameJoinPrompt(
|
||||
fmt.Sprintf("/login?return_url=/snake/%s", gameID),
|
||||
fmt.Sprintf("/register?return_url=/snake/%s", gameID),
|
||||
fmt.Sprintf("/snake/%s", gameID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
templ NicknamePage(gameID string) {
|
||||
@layouts.Base("Snake - Join") {
|
||||
@components.NicknamePrompt(fmt.Sprintf("/snake/%s/join", gameID))
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package pages
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
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/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
// keydownScript builds the inline JS for a single data-on:keydown handler
|
||||
// that dispatches WASD/arrow keys to direction POST endpoints.
|
||||
func keydownScript(gameID string) string {
|
||||
return fmt.Sprintf(
|
||||
"const k=evt.key;"+
|
||||
"if(k==='w'||k==='ArrowUp'){evt.preventDefault();%s}"+
|
||||
"else if(k==='s'||k==='ArrowDown'){evt.preventDefault();%s}"+
|
||||
"else if(k==='a'||k==='ArrowLeft'){evt.preventDefault();%s}"+
|
||||
"else if(k==='d'||k==='ArrowRight'){evt.preventDefault();%s}",
|
||||
datastar.PostSSE("/api/snake/%s/dir?d=0", gameID),
|
||||
datastar.PostSSE("/api/snake/%s/dir?d=1", gameID),
|
||||
datastar.PostSSE("/api/snake/%s/dir?d=2", gameID),
|
||||
datastar.PostSSE("/api/snake/%s/dir?d=3", gameID),
|
||||
)
|
||||
}
|
||||
|
||||
func GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<main class=\"snake-wrapper flex flex-col items-center gap-4 p-4\" data-signals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(`{"chatMsg":""}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/pages/game.templ`, Line: 33, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" data-init=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(datastar.GetSSE("/snake/%s/events", gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/pages/game.templ`, Line: 34, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-on:keydown.throttle_100ms=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(keydownScript(gameID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/snakegame/pages/game.templ`, Line: 35, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" tabindex=\"0\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = components.BackToLobby().Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<h1 class=\"text-3xl font-bold\">~~~~</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = snakecomponents.PlayerList(sg, mySlot).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = snakecomponents.StatusBanner(sg, mySlot, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"snake-game-area\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = snakecomponents.Board(sg).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = snakecomponents.Chat(messages, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = snakecomponents.Board(sg).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
} else if sg.Mode == snake.ModeMultiplayer {
|
||||
templ_7745c5c3_Err = snakecomponents.Chat(messages, gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||
templ_7745c5c3_Err = snakecomponents.InviteLink(gameID).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Snake").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func JoinPage(gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = components.GameJoinPrompt(
|
||||
fmt.Sprintf("/login?return=/snake/%s", gameID),
|
||||
fmt.Sprintf("/register?return=/snake/%s", gameID),
|
||||
fmt.Sprintf("/snake/%s", gameID),
|
||||
).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Snake - Join").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func NicknamePage(gameID string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var8 == nil {
|
||||
templ_7745c5c3_Var8 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = components.NicknamePrompt(fmt.Sprintf("/api/snake/%s/join", gameID)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = layouts.Base("Snake - Join").Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,22 +1,21 @@
|
||||
// Package snakegame handles snake game routes, SSE event streaming, and chat.
|
||||
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) error {
|
||||
router.Get("/snake/{game_id}", HandleSnakePage(snakeStore, sessions))
|
||||
router.Get("/snake/{game_id}/events", HandleSnakeEvents(snakeStore, nc, sessions))
|
||||
|
||||
router.Route("/api/snake/{game_id}", func(r chi.Router) {
|
||||
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, 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))
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
62
features/snakegame/services/game_service.go
Normal file
62
features/snakegame/services/game_service.go
Normal 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)
|
||||
}
|
||||
239
game/store.go
239
game/store.go
@@ -1,239 +0,0 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PlayerSession struct {
|
||||
Player *Player
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
SaveGame(g *Game) error
|
||||
LoadGame(id string) (*Game, error)
|
||||
SaveGamePlayer(gameID string, player *Player, slot int) error
|
||||
LoadGamePlayers(gameID string) ([]*Player, error)
|
||||
DeleteGame(id string) error
|
||||
}
|
||||
|
||||
type GameStore struct {
|
||||
games map[string]*GameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
notifyFunc func(gameID string)
|
||||
}
|
||||
|
||||
func NewGameStore() *GameStore {
|
||||
return &GameStore{
|
||||
games: make(map[string]*GameInstance),
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPersister(p Persister) {
|
||||
gs.persister = p
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetNotifyFunc(f func(gameID string)) {
|
||||
gs.notifyFunc = f
|
||||
}
|
||||
|
||||
func (gs *GameStore) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if gs.notifyFunc != nil {
|
||||
gs.notifyFunc(gameID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) Create() *GameInstance {
|
||||
id := GenerateID(4)
|
||||
gi := NewGameInstance(id)
|
||||
gi.persister = gs.persister
|
||||
gi.notify = gs.makeNotify(id)
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
if gs.persister != nil {
|
||||
gs.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
return gi
|
||||
}
|
||||
|
||||
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
||||
gs.gamesMu.RLock()
|
||||
gi, ok := gs.games[id]
|
||||
gs.gamesMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gi, true
|
||||
}
|
||||
|
||||
if gs.persister == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
game, err := gs.persister.LoadGame(id)
|
||||
if err != nil || game == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := gs.persister.LoadGamePlayers(id)
|
||||
for _, p := range players {
|
||||
if p.Color == 1 {
|
||||
game.Players[0] = p
|
||||
} else if p.Color == 2 {
|
||||
game.Players[1] = p
|
||||
}
|
||||
}
|
||||
|
||||
gi = &GameInstance{
|
||||
game: game,
|
||||
persister: gs.persister,
|
||||
notify: gs.makeNotify(id),
|
||||
}
|
||||
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
return gi, true
|
||||
}
|
||||
|
||||
func (gs *GameStore) Delete(id string) error {
|
||||
gs.gamesMu.Lock()
|
||||
delete(gs.games, id)
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
if gs.persister != nil {
|
||||
return gs.persister.DeleteGame(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateID(size int) string {
|
||||
b := make([]byte, size)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
type GameInstance struct {
|
||||
game *Game
|
||||
gameMu sync.RWMutex
|
||||
notify func()
|
||||
persister Persister
|
||||
}
|
||||
|
||||
func NewGameInstance(id string) *GameInstance {
|
||||
return &GameInstance{
|
||||
game: NewGame(id),
|
||||
notify: func() {},
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *GameInstance) ID() string {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game.ID
|
||||
}
|
||||
|
||||
func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
var slot int
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1
|
||||
gi.game.Players[0] = ps.Player
|
||||
slot = 0
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
slot = 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot)
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
func (gi *GameInstance) GetGame() *Game {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game
|
||||
}
|
||||
|
||||
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.ID == pid {
|
||||
return p.Color
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
newGI := gs.Create()
|
||||
newID := newGI.ID()
|
||||
gi.game.RematchGameID = &newID
|
||||
|
||||
if gi.persister != nil {
|
||||
if err := gi.persister.SaveGame(gi.game); err != nil {
|
||||
gs.Delete(newID)
|
||||
gi.game.RematchGameID = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return newGI
|
||||
}
|
||||
|
||||
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
row, ok := gi.game.DropPiece(col, playerColor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.game.CheckWin(row, col) {
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.Color == playerColor {
|
||||
gi.game.Winner = p
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if gi.game.CheckDraw() {
|
||||
// Status already set by CheckDraw
|
||||
} else {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
6
go.mod
6
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
55
main.go
55
main.go
@@ -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,20 +74,16 @@ func run(ctx context.Context) error {
|
||||
defer cleanupNATS()
|
||||
|
||||
// Game stores
|
||||
store := game.NewGameStore()
|
||||
store.SetPersister(db.NewGamePersister(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()
|
||||
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||
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
|
||||
})
|
||||
|
||||
chatPersister := db.NewChatPersister(queries)
|
||||
|
||||
// Router
|
||||
logger := log.Logger
|
||||
r := chi.NewMux()
|
||||
@@ -94,9 +93,7 @@ func run(ctx context.Context) error {
|
||||
sessionManager.LoadAndSave,
|
||||
)
|
||||
|
||||
if err := router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, chatPersister, assets); err != nil {
|
||||
return fmt.Errorf("setting up routes: %w", err)
|
||||
}
|
||||
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
|
||||
|
||||
// HTTP server
|
||||
srv := &http.Server{
|
||||
@@ -106,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 {
|
||||
|
||||
@@ -40,7 +40,7 @@ func SetupNATS(ctx context.Context) (*nats.Conn, func(), error) {
|
||||
|
||||
cleanup := func() {
|
||||
nc.Close()
|
||||
ns.Close()
|
||||
ns.Close() //nolint:errcheck
|
||||
}
|
||||
|
||||
return nc, cleanup, nil
|
||||
|
||||
18
player/player.go
Normal file
18
player/player.go
Normal 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)
|
||||
}
|
||||
@@ -2,25 +2,25 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/c4/config"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"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(
|
||||
@@ -28,26 +28,25 @@ func SetupRoutes(
|
||||
queries *repository.Queries,
|
||||
sessions *scs.SessionManager,
|
||||
nc *nats.Conn,
|
||||
store *game.GameStore,
|
||||
store *connect4.Store,
|
||||
snakeStore *snake.SnakeStore,
|
||||
chatPersister *db.ChatPersister,
|
||||
assets embed.FS,
|
||||
) error {
|
||||
) {
|
||||
// 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, chatPersister)
|
||||
snakegame.SetupRoutes(router, snakeStore, nc, sessions)
|
||||
|
||||
return nil
|
||||
c4game.SetupRoutes(router, store, c4Svc, sessions)
|
||||
snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
|
||||
}
|
||||
|
||||
func setupReload(router chi.Router) {
|
||||
|
||||
@@ -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,6 +30,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
||||
sessionManager := scs.New()
|
||||
sessionManager.Store = store
|
||||
sessionManager.Lifetime = 30 * 24 * time.Hour
|
||||
sessionManager.Cookie.Name = "games_session"
|
||||
sessionManager.Cookie.Path = "/"
|
||||
sessionManager.Cookie.HttpOnly = true
|
||||
sessionManager.Cookie.Secure = false
|
||||
@@ -29,3 +40,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package snake implements snake game logic, state management, and persistence.
|
||||
package snake
|
||||
|
||||
import "math/rand"
|
||||
|
||||
@@ -61,17 +61,15 @@ func (si *SnakeGameInstance) countdownPhase() {
|
||||
si.initGame()
|
||||
si.game.Status = StatusInProgress
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
return
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
// No DB save during countdown ticks — state is transient
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
}
|
||||
@@ -98,6 +96,7 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
defer ticker.Stop()
|
||||
|
||||
lastInput := time.Now()
|
||||
lastSave := time.Now()
|
||||
var moveAccum time.Duration
|
||||
|
||||
for {
|
||||
@@ -123,8 +122,8 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
// Inactivity timeout
|
||||
if time.Since(lastInput) > inactivityLimit {
|
||||
si.game.Status = StatusFinished
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
@@ -175,13 +174,10 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
alive := AliveCount(state)
|
||||
gameOver := false
|
||||
if si.game.Mode == ModeSinglePlayer {
|
||||
// Single player ends when the player dies (alive == 0)
|
||||
if alive == 0 {
|
||||
gameOver = true
|
||||
// No winner in single player - just final score
|
||||
}
|
||||
} else {
|
||||
// Multiplayer ends when 1 or fewer alive
|
||||
if alive <= 1 {
|
||||
gameOver = true
|
||||
winnerIdx := LastAlive(state)
|
||||
@@ -195,8 +191,10 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
si.game.Status = StatusFinished
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
// Throttle DB saves: persist on game over or every 2 seconds
|
||||
if si.queries != nil && (gameOver || time.Since(lastSave) >= 2*time.Second) {
|
||||
si.save() //nolint:errcheck
|
||||
lastSave = time.Now()
|
||||
}
|
||||
|
||||
si.gameMu.Unlock()
|
||||
|
||||
141
snake/persist.go
Normal file
141
snake/persist.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (si *SnakeGameInstance) save() error {
|
||||
err := saveSnakeGame(si.queries, si.game)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("game_id", si.game.ID).Msg("failed to save snake game")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) savePlayer(player *Player) error {
|
||||
err := saveSnakePlayer(si.queries, si.game.ID, player)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("game_id", si.game.ID).Msg("failed to save snake player")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// saveSnakeGame persists the snake game state via upsert.
|
||||
func saveSnakeGame(queries *repository.Queries, sg *SnakeGame) error {
|
||||
boardJSON := "{}"
|
||||
var gridWidth, gridHeight *int64
|
||||
if sg.State != nil {
|
||||
boardJSON = sg.State.ToJSON()
|
||||
w, h := int64(sg.State.Width), int64(sg.State.Height)
|
||||
gridWidth, gridHeight = &w, &h
|
||||
}
|
||||
|
||||
var winnerUserID *string
|
||||
if sg.Winner != nil && sg.Winner.UserID != nil {
|
||||
winnerUserID = sg.Winner.UserID
|
||||
}
|
||||
|
||||
return queries.UpsertSnakeGame(context.Background(), repository.UpsertSnakeGameParams{
|
||||
ID: sg.ID,
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
GridWidth: gridWidth,
|
||||
GridHeight: gridHeight,
|
||||
GameMode: int64(sg.Mode),
|
||||
SnakeSpeed: int64(sg.Speed),
|
||||
WinnerUserID: winnerUserID,
|
||||
RematchGameID: sg.RematchGameID,
|
||||
Score: int64(sg.Score),
|
||||
})
|
||||
}
|
||||
|
||||
func saveSnakePlayer(queries *repository.Queries, gameID string, player *Player) error {
|
||||
var userID, guestPlayerID *string
|
||||
if player.UserID != nil {
|
||||
userID = player.UserID
|
||||
} else {
|
||||
id := string(player.ID)
|
||||
guestPlayerID = &id
|
||||
}
|
||||
|
||||
return queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Slot + 1),
|
||||
Slot: int64(player.Slot),
|
||||
})
|
||||
}
|
||||
|
||||
func loadSnakeGame(queries *repository.Queries, id string) (*SnakeGame, error) {
|
||||
row, err := queries.GetSnakeGame(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snakeGameFromRow(row)
|
||||
}
|
||||
|
||||
func loadSnakePlayers(queries *repository.Queries, id string) ([]*Player, error) {
|
||||
rows, err := queries.GetSnakePlayers(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snakePlayersFromRows(rows), nil
|
||||
}
|
||||
|
||||
// Domain ↔ DB mapping helpers.
|
||||
|
||||
func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
|
||||
state, err := GameStateFromJSON(row.Board)
|
||||
if err != nil {
|
||||
state = &GameState{}
|
||||
}
|
||||
if row.GridWidth != nil {
|
||||
state.Width = int(*row.GridWidth)
|
||||
}
|
||||
if row.GridHeight != nil {
|
||||
state.Height = int(*row.GridHeight)
|
||||
}
|
||||
|
||||
sg := &SnakeGame{
|
||||
ID: row.ID,
|
||||
State: state,
|
||||
Players: make([]*Player, 8),
|
||||
Status: Status(row.Status),
|
||||
Mode: GameMode(row.GameMode),
|
||||
Score: int(row.Score),
|
||||
Speed: int(row.SnakeSpeed),
|
||||
}
|
||||
|
||||
if row.RematchGameID != nil {
|
||||
sg.RematchGameID = row.RematchGameID
|
||||
}
|
||||
|
||||
return sg, nil
|
||||
}
|
||||
|
||||
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
|
||||
players := make([]*Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
p := &Player{
|
||||
Nickname: row.Nickname,
|
||||
Slot: int(row.Slot),
|
||||
}
|
||||
|
||||
if row.UserID != nil {
|
||||
p.UserID = row.UserID
|
||||
p.ID = player.ID(*row.UserID)
|
||||
} else if row.GuestPlayerID != nil {
|
||||
p.ID = player.ID(*row.GuestPlayerID)
|
||||
}
|
||||
|
||||
players = append(players, p)
|
||||
}
|
||||
return players
|
||||
}
|
||||
@@ -1,36 +1,27 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
type Persister interface {
|
||||
SaveSnakeGame(sg *SnakeGame) error
|
||||
LoadSnakeGame(id string) (*SnakeGame, error)
|
||||
SaveSnakePlayer(gameID string, player *Player) error
|
||||
LoadSnakePlayers(gameID string) ([]*Player, error)
|
||||
DeleteSnakeGame(id string) error
|
||||
}
|
||||
|
||||
type SnakeStore struct {
|
||||
games map[string]*SnakeGameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
games map[string]*SnakeGameInstance
|
||||
gamesMu sync.RWMutex
|
||||
queries *repository.Queries
|
||||
notifyFunc func(gameID string)
|
||||
}
|
||||
|
||||
func NewSnakeStore() *SnakeStore {
|
||||
func NewSnakeStore(queries *repository.Queries) *SnakeStore {
|
||||
return &SnakeStore{
|
||||
games: make(map[string]*SnakeGameInstance),
|
||||
games: make(map[string]*SnakeGameInstance),
|
||||
queries: queries,
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPersister(p Persister) {
|
||||
ss.persister = p
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
|
||||
ss.notifyFunc = f
|
||||
}
|
||||
@@ -47,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
||||
if speed <= 0 {
|
||||
speed = DefaultSpeed
|
||||
}
|
||||
id := generateID(4)
|
||||
id := player.GenerateID(4)
|
||||
sg := &SnakeGame{
|
||||
ID: id,
|
||||
State: &GameState{
|
||||
@@ -60,18 +51,18 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
||||
Speed: speed,
|
||||
}
|
||||
si := &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
store: ss,
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
queries: ss.queries,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
ss.gamesMu.Lock()
|
||||
ss.games[id] = si
|
||||
ss.gamesMu.Unlock()
|
||||
|
||||
if ss.persister != nil {
|
||||
ss.persister.SaveSnakeGame(sg)
|
||||
if ss.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
return si
|
||||
@@ -86,16 +77,16 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
||||
return si, true
|
||||
}
|
||||
|
||||
if ss.persister == nil {
|
||||
if ss.queries == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sg, err := ss.persister.LoadSnakeGame(id)
|
||||
sg, err := loadSnakeGame(ss.queries, id)
|
||||
if err != nil || sg == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := ss.persister.LoadSnakePlayers(id)
|
||||
players, _ := loadSnakePlayers(ss.queries, id)
|
||||
if sg.Players == nil {
|
||||
sg.Players = make([]*Player, 8)
|
||||
}
|
||||
@@ -106,10 +97,10 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
||||
}
|
||||
|
||||
si = &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
store: ss,
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
queries: ss.queries,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
ss.gamesMu.Lock()
|
||||
@@ -129,8 +120,8 @@ func (ss *SnakeStore) Delete(id string) error {
|
||||
si.Stop()
|
||||
}
|
||||
|
||||
if ss.persister != nil {
|
||||
return ss.persister.DeleteSnakeGame(id)
|
||||
if ss.queries != nil {
|
||||
return ss.queries.DeleteSnakeGame(context.Background(), id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -158,14 +149,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
||||
}
|
||||
|
||||
type SnakeGameInstance struct {
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
|
||||
notify func()
|
||||
persister Persister
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
notify func()
|
||||
queries *repository.Queries
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) ID() string {
|
||||
@@ -181,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 {
|
||||
@@ -214,9 +205,9 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
|
||||
player.Slot = slot
|
||||
si.game.Players[slot] = player
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakePlayer(si.game.ID, player)
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.savePlayer(player) //nolint:errcheck
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
si.notify()
|
||||
@@ -301,17 +292,11 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
||||
}
|
||||
si.game.RematchGameID = &newID
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
|
||||
si.notify()
|
||||
return newSI
|
||||
}
|
||||
|
||||
func generateID(size int) string {
|
||||
b := make([]byte, size)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -100,7 +109,7 @@ type SnakeGame struct {
|
||||
Speed int // cells per second
|
||||
}
|
||||
|
||||
// Speed presets
|
||||
// SpeedPreset defines a named speed option for the snake game.
|
||||
type SpeedPreset struct {
|
||||
Name string
|
||||
Speed int
|
||||
@@ -129,7 +138,7 @@ func (sg *SnakeGame) PlayerCount() int {
|
||||
return count
|
||||
}
|
||||
|
||||
// Grid presets
|
||||
// GridPreset defines a named grid size option for the snake game.
|
||||
type GridPreset struct {
|
||||
Name string
|
||||
Width int
|
||||
@@ -163,7 +172,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame {
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Snake colors (hex values for CSS)
|
||||
// SnakeColors are hex color values for CSS, indexed by player slot.
|
||||
var SnakeColors = []string{
|
||||
"#00b894", // 1: Green
|
||||
"#e17055", // 2: Orange
|
||||
|
||||
@@ -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
10
version/version.go
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user