Compare commits
67 Commits
v0.1.2
...
feat/datas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551190b801 | ||
| 8789c5414e | |||
|
|
7a1c91c858 | ||
|
|
2ad0abaf44 | ||
|
|
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 | ||
|
|
8c3b3fc6ea | ||
|
|
2df20c2840 | ||
|
|
6d4f3eb821 | ||
|
|
e68e4b48f5 | ||
|
|
91b5f2b80c |
@@ -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
|
||||
|
||||
19
.env.example
19
.env.example
@@ -1,5 +1,20 @@
|
||||
# Application URL for invite links (defaults to https://games.adriatica.io)
|
||||
# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO.
|
||||
# LOG_LEVEL=DEBUG
|
||||
|
||||
# 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
|
||||
|
||||
# Server port (defaults to 7331)
|
||||
# Server port. Defaults to 7331.
|
||||
# PORT=7331
|
||||
|
||||
# Goose CLI migration config (only needed for running goose manually)
|
||||
# Gitea API token for downloading datastar-pro from private repo (CI/Docker only).
|
||||
# Not needed for local dev — falls back to copying from ../optional/.
|
||||
# VENDOR_TOKEN=
|
||||
|
||||
GOOSE_DRIVER=sqlite3
|
||||
GOOSE_DBSTRING=data/games.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
|
||||
GOOSE_MIGRATION_DIR=db/migrations
|
||||
|
||||
@@ -1,17 +1,55 @@
|
||||
name: Deploy c4
|
||||
name: CI / Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
DEPLOY_DIR: /home/ryan/c4
|
||||
DEPLOY_DIR: /home/ryan/games
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Generate templ
|
||||
run: go tool templ generate
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
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
|
||||
|
||||
- name: Run linter
|
||||
run: golangci-lint run
|
||||
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
needs: [test, lint]
|
||||
runs-on: games
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Need full history for git describe
|
||||
|
||||
- name: Sync to deploy directory
|
||||
run: |
|
||||
@@ -21,8 +59,15 @@ jobs:
|
||||
- name: Ensure data directory exists with correct ownership
|
||||
run: |
|
||||
mkdir -p $DEPLOY_DIR/data
|
||||
# UID 5 / GID 60 = games:games in the container (debian:bookworm-slim)
|
||||
sudo chown 5:60 $DEPLOY_DIR/data
|
||||
|
||||
- name: Rebuild and restart
|
||||
run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans
|
||||
env:
|
||||
VENDOR_TOKEN: ${{ secrets.VENDOR_TOKEN }}
|
||||
run: |
|
||||
cd $DEPLOY_DIR
|
||||
VERSION=$(git describe --tags --always)
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
VERSION=$VERSION COMMIT=$COMMIT VENDOR_TOKEN=$VENDOR_TOKEN docker compose up -d --build --remove-orphans
|
||||
|
||||
- name: Prune unused images
|
||||
run: docker image prune -f
|
||||
|
||||
47
.gitignore
vendored
47
.gitignore
vendored
@@ -1,8 +1,41 @@
|
||||
c4
|
||||
c4.db
|
||||
data/
|
||||
.env
|
||||
# Allowlisting gitignore: ignore everything, then un-ignore what we track.
|
||||
# source: https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
|
||||
# Deploy artifacts
|
||||
c4-deploy-*.tar.gz
|
||||
c4-deploy-*_b64*.txt
|
||||
# Ignore everything
|
||||
*
|
||||
|
||||
# But not these files...
|
||||
!.gitignore
|
||||
|
||||
!*.go
|
||||
!*.templ
|
||||
!*.sql
|
||||
!go.sum
|
||||
!go.mod
|
||||
!Taskfile.yml
|
||||
!sqlc.yaml
|
||||
!.golangci.yml
|
||||
!.gitea/workflows/*.yml
|
||||
|
||||
!.env.example
|
||||
!LICENSE
|
||||
!AGENTS.md
|
||||
|
||||
!assets/**/*
|
||||
|
||||
# Generated files stay out of version control
|
||||
*_templ.go
|
||||
assets/css/output.css
|
||||
|
||||
# Downloaded client-side libs (fetched by cmd/downloader)
|
||||
assets/js/datastar/*
|
||||
assets/css/daisyui/*
|
||||
|
||||
# Deploy scripts and configs
|
||||
!deploy/*.sh
|
||||
!deploy/*.service
|
||||
!docker-compose.yml
|
||||
!Dockerfile
|
||||
|
||||
# ...even if they are in subdirectories
|
||||
!*/
|
||||
|
||||
45
.golangci.yml
Normal file
45
.golangci.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- staticcheck
|
||||
- gosec
|
||||
- bodyclose
|
||||
- sqlclosecheck
|
||||
- misspell
|
||||
- errname
|
||||
- copyloopvar
|
||||
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- "-ST1001" # dot imports
|
||||
- "-ST1003" # naming conventions
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unhandled errors — redundant with errcheck
|
||||
- G107 # HTTP requests with variable URLs — expected in a web app
|
||||
- G115 # integer overflow conversion
|
||||
- G301 # directory permissions 0750 — 0755 is standard for data dirs
|
||||
- G404 # weak random — acceptable for game IDs and player IDs
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/ryanhamamura/games
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
218
AGENTS.md
Normal file
218
AGENTS.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for AI coding agents working in this repository.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Development
|
||||
task live # Hot-reload dev server (templ + tailwind + air)
|
||||
task build # Production build to bin/games
|
||||
task run # Build and run server
|
||||
task download # Download pinned client-side libs (datastar-pro, daisyui)
|
||||
|
||||
# Quality
|
||||
task test # Run all tests: go test ./...
|
||||
task lint # Run linter: golangci-lint run
|
||||
|
||||
# Single test
|
||||
go test -run TestName ./path/to/package
|
||||
go test -v -run TestHandleLogin_Success ./features/auth
|
||||
|
||||
# Code generation
|
||||
task build:templ # Compile .templ files (go tool templ generate)
|
||||
task build:styles # Build TailwindCSS (go tool gotailwind)
|
||||
```
|
||||
|
||||
Tools (templ, air, gotailwind, goose, sqlc) are managed via Go 1.25's `tool` directive in go.mod — no separate installs needed.
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
- **Never merge PRs without explicit user approval.** Create the PR, push changes, then wait.
|
||||
- Always use PRs via `tea` CLI — never push directly to main.
|
||||
- Write semantic commit messages focusing on "why" not "what".
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
games/
|
||||
├── connect4/, snake/ # Game logic packages (pure Go, no HTTP)
|
||||
├── features/ # Feature modules (handlers, routes, templates)
|
||||
│ ├── auth/ # Login/register (standard HTTP, not SSE)
|
||||
│ ├── c4game/ # Connect 4 UI + services
|
||||
│ ├── snakegame/ # Snake UI + services
|
||||
│ ├── lobby/ # Game lobby
|
||||
│ └── common/ # Shared components, layouts
|
||||
├── chat/ # Reusable chat room (NATS + optional DB persistence)
|
||||
├── auth/ # Password hashing/validation (pure, no HTTP)
|
||||
├── db/ # SQLite, migrations, sqlc queries
|
||||
├── cmd/downloader/ # Build-time tool: fetches datastar-pro + daisyui
|
||||
├── assets/ # Static files (embedded in prod, filesystem in dev)
|
||||
└── config/, logging/, nats/, sessions/, router/, player/, version/
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Imports
|
||||
|
||||
Three groups separated by blank lines: stdlib, third-party, local. Enforced by goimports with `local-prefixes: github.com/ryanhamamura/games`.
|
||||
|
||||
```go
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```go
|
||||
// Wrap errors with context
|
||||
return fmt.Errorf("loading game %s: %w", id, err)
|
||||
|
||||
// Combine cleanup errors with errors.Join
|
||||
return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close())
|
||||
|
||||
// Best-effort operations
|
||||
nc.Publish(subject, nil) //nolint:errcheck // best-effort notification
|
||||
|
||||
// HTTP errors
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- Focus on **why**, not **how**. Avoid superfluous comments.
|
||||
- Package comments at top of primary file.
|
||||
- Function comments for exported functions.
|
||||
|
||||
## Go Patterns
|
||||
|
||||
### Dependency Injection via Closures
|
||||
|
||||
Handlers receive dependencies and return `http.HandlerFunc`:
|
||||
|
||||
```go
|
||||
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Cleanup Function Returns
|
||||
|
||||
Infrastructure init functions return a cleanup func the caller defers:
|
||||
|
||||
```go
|
||||
cleanupDB, err := db.Init(cfg.DBPath)
|
||||
defer cleanupDB()
|
||||
```
|
||||
|
||||
### Store/Instance Pattern
|
||||
|
||||
Game state uses a two-tier pattern: a thread-safe **Store** (map + RWMutex) holding **Instance** wrappers (individual game + own mutex + DB queries). Stores lazy-load from DB on cache miss.
|
||||
|
||||
### Build Tags
|
||||
|
||||
`//go:build dev` and `//go:build !dev` switch behavior for static asset serving (filesystem vs embedded hashfs) and config loading.
|
||||
|
||||
## Templ + Datastar Patterns
|
||||
|
||||
### Architecture: Everything Is a Stream
|
||||
|
||||
The core mental model: **the server owns all state and continuously projects it to the browser over SSE**. There is no client-side state management. The browser connects to an event stream, and the server pushes full HTML fragments whenever something changes. Datastar morphs these into the DOM — the client is a thin rendering surface.
|
||||
|
||||
User actions (clicks, keypresses) trigger short POST/DELETE requests back to the server. The server mutates state, publishes a NATS signal, and every connected SSE stream picks up the change and re-renders. The client never needs to know what changed — it just receives the new truth and morphs to match.
|
||||
|
||||
This means: **always send whole components down the wire.** Don't try to diff or send minimal patches. Render the full templ component, call `sse.PatchElementTempl()`, and let Datastar's morph handle the rest. The only exception is appending to a list (e.g. chat messages).
|
||||
|
||||
**Signals follow command-query segregation.** Signals are *commands* — they carry the user's intent to the server (form input values, button clicks). The SSE stream is the *query* — it continuously projects the server's truth into the DOM. Keep signals thin: form input buffers (`chatMsg`, `nickname`), pure UI state the server never needs (`activeTab`), and request indicators. Don't use signals to hold application state — that arrives from the server via SSE.
|
||||
|
||||
### SSE Event Loop
|
||||
|
||||
Both game event handlers follow the same structure:
|
||||
1. Subscribe to NATS channels **before** creating SSE (avoids missed messages)
|
||||
2. Send initial full-state patch
|
||||
3. `select` loop over: context done, game updates (drain channel first), chat messages (append), 1-second heartbeat (full re-render)
|
||||
|
||||
```go
|
||||
// Handler side — long-lived SSE with Brotli compression
|
||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||
))
|
||||
sse.PatchElementTempl(components.GameBoard(game))
|
||||
|
||||
// Template side — disable Datastar's default SSE cancellation on interaction
|
||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
||||
```
|
||||
|
||||
### Client-Server Interactions
|
||||
|
||||
```go
|
||||
// Trigger SSE actions from templates
|
||||
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
|
||||
data-on:click={ datastar.DeleteSSE("/games/%s", g.ID) }
|
||||
|
||||
// Read client signals in handlers
|
||||
var signals struct { ChatMsg string `json:"chatMsg"` }
|
||||
datastar.ReadSignals(r, &signals)
|
||||
|
||||
// Clear input after submission
|
||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""})
|
||||
|
||||
// Redirect via SSE
|
||||
sse.Redirectf("/games/%s", newGame.ID())
|
||||
```
|
||||
|
||||
### Appending Elements (Chat Messages)
|
||||
|
||||
The one exception to whole-component morphing is chat, where messages are appended individually:
|
||||
|
||||
```go
|
||||
sse.PatchElementTempl(
|
||||
chatcomponents.ChatMessage(msg, cfg),
|
||||
datastar.WithSelectorID("c4-chat-history"),
|
||||
datastar.WithModeAppend(),
|
||||
)
|
||||
```
|
||||
|
||||
### Datastar Template Attributes
|
||||
|
||||
- `data-signals` — declare reactive state
|
||||
- `data-bind` — two-way input binding
|
||||
- `data-show` — conditional visibility
|
||||
- `data-class` — reactive CSS classes
|
||||
- `data-morph-ignore` — prevent SSE from overwriting an element (e.g. chat input)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
task test # All tests
|
||||
go test -run TestHandleLogin_Success ./features/auth # Single test
|
||||
go test -v ./features/auth # Verbose package
|
||||
```
|
||||
|
||||
- Use `testutil.NewTestDB(t)` for tests needing a database
|
||||
- Use `testutil.NewTestSessionManager(db)` for session-aware tests
|
||||
- Use `config.LoadForTest()` to set safe defaults without .env
|
||||
- Tests use external test packages (`package auth_test`)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Templates | templ (type-safe HTML) |
|
||||
| Reactivity | Datastar Pro (SSE-driven) |
|
||||
| CSS | TailwindCSS v4 + daisyUI |
|
||||
| Router | chi/v5 |
|
||||
| Sessions | scs/v2 (SQLite-backed) |
|
||||
| Database | SQLite (modernc.org/sqlite) |
|
||||
| Migrations | goose (embedded SQL) |
|
||||
| SQL codegen | sqlc |
|
||||
| Pub/sub | Embedded NATS (nil-payload signals) |
|
||||
| Logging | zerolog + slog (bridged via slog-zerolog) |
|
||||
41
Dockerfile
41
Dockerfile
@@ -1,29 +1,28 @@
|
||||
FROM golang:1.25.4-bookworm AS build
|
||||
FROM docker.io/golang:1.25.4-alpine AS build
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
|
||||
RUN apk add --no-cache upx
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=secret,id=vendor_token \
|
||||
VENDOR_TOKEN=$(cat /run/secrets/vendor_token) \
|
||||
go run cmd/downloader/main.go
|
||||
|
||||
RUN go tool templ generate
|
||||
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /c4 .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
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 debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /c4 /usr/local/bin/c4
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir data && chown games:games data
|
||||
|
||||
USER games
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -qO /dev/null http://localhost:8080/
|
||||
|
||||
CMD ["c4"]
|
||||
FROM scratch
|
||||
ENV PORT=8080
|
||||
COPY --from=build /bin/games /
|
||||
ENTRYPOINT ["/games"]
|
||||
|
||||
90
Taskfile.yml
Normal file
90
Taskfile.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
download:
|
||||
desc: Download pinned client-side libs
|
||||
cmds:
|
||||
- go run cmd/downloader/main.go
|
||||
status:
|
||||
- test -f assets/js/datastar/datastar.js
|
||||
- test -f assets/css/daisyui/daisyui.js
|
||||
|
||||
build:templ:
|
||||
desc: Compile .templ files to Go
|
||||
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/games
|
||||
cmds:
|
||||
- go build -o bin/games .
|
||||
deps:
|
||||
- download
|
||||
- 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:
|
||||
- go tool gotailwind -i assets/css/input.css -o assets/css/output.css -w
|
||||
|
||||
live:server:
|
||||
desc: Run server with hot-reload via air
|
||||
cmds:
|
||||
- |
|
||||
go tool air \
|
||||
-build.cmd "go build -tags=dev -o tmp/bin/games ." \
|
||||
-build.bin "tmp/bin/games" \
|
||||
-build.exclude_dir "data,bin,tmp,deploy" \
|
||||
-build.include_ext "go,templ" \
|
||||
-misc.clean_on_exit "true"
|
||||
|
||||
live:
|
||||
desc: Dev mode with hot-reload
|
||||
deps:
|
||||
- download
|
||||
- live:templ
|
||||
- live:styles
|
||||
- live:server
|
||||
|
||||
test:
|
||||
desc: Run the test suite
|
||||
cmds:
|
||||
- go test ./...
|
||||
|
||||
lint:
|
||||
desc: Run golangci-lint
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
run:
|
||||
desc: Build and run the server
|
||||
cmds:
|
||||
- ./bin/games
|
||||
deps:
|
||||
- build
|
||||
|
||||
default:
|
||||
desc: Run the default task (live)
|
||||
cmds:
|
||||
- task: live
|
||||
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
@@ -1,8 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source not "./daisyui{,*}.mjs";
|
||||
@plugin "./daisyui.mjs";
|
||||
@plugin "./daisyui-theme.mjs" {
|
||||
@source not "./daisyui/daisyui{,*}.js";
|
||||
@plugin "./daisyui/daisyui.js";
|
||||
@plugin "./daisyui/daisyui-theme.js" {
|
||||
name: "stealth";
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
assets/js/README.md
Normal file
1
assets/js/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Downloaded by cmd/downloader at build time.
|
||||
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});
|
||||
}
|
||||
317
cmd/downloader/main.go
Normal file
317
cmd/downloader/main.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/games/assets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("failure", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned dependency versions — update these to upgrade.
|
||||
const (
|
||||
datastarVersion = "v1.0.0-RC.8" // Pro build — fetched from private Gitea repo
|
||||
daisyuiVersion = "v5.5.19"
|
||||
)
|
||||
|
||||
// dependencies tracks pinned versions alongside their GitHub coordinates
|
||||
// so the version check can look up the latest release for each.
|
||||
var dependencies = []dependency{
|
||||
{name: "datastar", owner: "starfederation", repo: "datastar", pinnedVersion: datastarVersion},
|
||||
{name: "daisyui", owner: "saadeghi", repo: "daisyui", pinnedVersion: daisyuiVersion},
|
||||
}
|
||||
|
||||
type dependency struct {
|
||||
name string
|
||||
owner string
|
||||
repo string
|
||||
pinnedVersion string
|
||||
}
|
||||
|
||||
// datastar-pro sources, in order of preference.
|
||||
const (
|
||||
giteaRawURL = "https://gitea.adriatica.io/ryan/vendor-libs/raw/branch/main/datastar/datastar.js"
|
||||
localFallbackPath = "../optional/web/resources/static/datastar/datastar.js"
|
||||
)
|
||||
|
||||
func run() error {
|
||||
jsDir := assets.DirectoryPath + "/js/datastar"
|
||||
cssDir := assets.DirectoryPath + "/css/daisyui"
|
||||
|
||||
daisyuiBase := "https://github.com/saadeghi/daisyui/releases/download/" + daisyuiVersion + "/"
|
||||
|
||||
downloads := map[string]string{
|
||||
daisyuiBase + "daisyui.js": cssDir + "/daisyui.js",
|
||||
daisyuiBase + "daisyui-theme.js": cssDir + "/daisyui-theme.js",
|
||||
}
|
||||
|
||||
directories := []string{jsDir, cssDir}
|
||||
|
||||
if err := removeDirectories(directories); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := createDirectories(directories); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := acquireDatastar(jsDir + "/datastar.js"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := download(downloads); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkForUpdates()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// acquireDatastar fetches datastar-pro from the private Gitea repo when
|
||||
// GITEA_TOKEN is set, otherwise copies from the local optional project.
|
||||
func acquireDatastar(dest string) error {
|
||||
if token := os.Getenv("VENDOR_TOKEN"); token != "" {
|
||||
slog.Info("downloading datastar-pro from private repo...")
|
||||
return downloadWithAuth(giteaRawURL, dest, token)
|
||||
}
|
||||
|
||||
slog.Info("copying datastar-pro from local fallback...", "src", localFallbackPath)
|
||||
return copyFile(localFallbackPath, dest)
|
||||
}
|
||||
|
||||
func copyFile(src, dest string) error {
|
||||
in, err := os.Open(src) //nolint:gosec // paths are hardcoded constants
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", src, err)
|
||||
}
|
||||
defer in.Close() //nolint:errcheck
|
||||
|
||||
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close() //nolint:errcheck
|
||||
return fmt.Errorf("copy to %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadWithAuth(rawURL, dest, token string) error {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request for %s: %w", rawURL, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is built from compile-time constants
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", rawURL, err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: status %s", rawURL, resp.Status)
|
||||
}
|
||||
|
||||
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
out.Close() //nolint:errcheck
|
||||
return fmt.Errorf("write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkForUpdates queries the GitHub releases API for each dependency
|
||||
// and logs a notice if a newer version is available. Failures are
|
||||
// logged but never cause the download to fail.
|
||||
func checkForUpdates() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, dep := range dependencies {
|
||||
wg.Go(func() {
|
||||
latest, err := latestGitHubRelease(dep.owner, dep.repo)
|
||||
if err != nil {
|
||||
slog.Warn("could not check for updates", "dependency", dep.name, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if latest != dep.pinnedVersion {
|
||||
slog.Warn("newer version available",
|
||||
"dependency", dep.name,
|
||||
"pinned", dep.pinnedVersion,
|
||||
"latest", latest,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// githubRelease is the minimal subset of the GitHub releases API response we need.
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
|
||||
func latestGitHubRelease(owner, repo string) (string, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.github.com",
|
||||
Path: fmt.Sprintf("/repos/%s/%s/releases/latest", owner, repo),
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching release: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
return release.TagName, nil
|
||||
}
|
||||
|
||||
func removeDirectories(dirs []string) error {
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(dirs))
|
||||
|
||||
for _, path := range dirs {
|
||||
wg.Go(func() {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
errCh <- fmt.Errorf("remove directory %s: %w", path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func createDirectories(dirs []string) error {
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(dirs))
|
||||
|
||||
for _, path := range dirs {
|
||||
wg.Go(func() {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
errCh <- fmt.Errorf("create directory %s: %w", path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func download(files map[string]string) error {
|
||||
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(rawURL, dest string) error {
|
||||
resp, err := http.Get(rawURL) //nolint:gosec,noctx // static URLs, simple tool
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", rawURL, err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: status %s", rawURL, resp.Status)
|
||||
}
|
||||
|
||||
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
out.Close() //nolint:errcheck
|
||||
return fmt.Errorf("write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
76
config/config.go
Normal file
76
config/config.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package config provides build-tag-switched application configuration.
|
||||
// The global Config singleton is initialized at import time via init()
|
||||
// and can be overridden in tests with LoadForTest().
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
Dev Environment = "dev"
|
||||
Prod Environment = "prod"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment Environment
|
||||
Host string
|
||||
Port string
|
||||
LogLevel zerolog.Level
|
||||
AppURL string
|
||||
DBPath string
|
||||
}
|
||||
|
||||
var (
|
||||
Global *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func init() {
|
||||
once.Do(func() {
|
||||
Global = Load()
|
||||
})
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loadBase() *Config {
|
||||
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"),
|
||||
Port: getEnv("PORT", "7331"),
|
||||
LogLevel: func() zerolog.Level {
|
||||
switch os.Getenv("LOG_LEVEL") {
|
||||
case "TRACE":
|
||||
return zerolog.TraceLevel
|
||||
case "DEBUG":
|
||||
return zerolog.DebugLevel
|
||||
case "INFO":
|
||||
return zerolog.InfoLevel
|
||||
case "WARN":
|
||||
return zerolog.WarnLevel
|
||||
case "ERROR":
|
||||
return zerolog.ErrorLevel
|
||||
default:
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
}(),
|
||||
AppURL: getEnv("APP_URL", "https://games.adriatica.io"),
|
||||
DBPath: getEnv("DB_PATH", "data/games.db"),
|
||||
}
|
||||
}
|
||||
9
config/config_dev.go
Normal file
9
config/config_dev.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build dev
|
||||
|
||||
package config
|
||||
|
||||
func Load() *Config {
|
||||
cfg := loadBase()
|
||||
cfg.Environment = Dev
|
||||
return cfg
|
||||
}
|
||||
9
config/config_prod.go
Normal file
9
config/config_prod.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !dev
|
||||
|
||||
package config
|
||||
|
||||
func Load() *Config {
|
||||
cfg := loadBase()
|
||||
cfg.Environment = Prod
|
||||
return cfg
|
||||
}
|
||||
19
config/config_test_helper.go
Normal file
19
config/config_test_helper.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// LoadForTest sets config.Global to safe defaults without reading
|
||||
// environment variables or .env files. Call this in TestMain or at the
|
||||
// top of tests that import packages which depend on config.Global.
|
||||
func LoadForTest() {
|
||||
Global = &Config{
|
||||
Environment: Dev,
|
||||
Host: "127.0.0.1",
|
||||
Port: "0",
|
||||
LogLevel: zerolog.WarnLevel,
|
||||
AppURL: "http://localhost:0",
|
||||
DBPath: ":memory:",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
57
db/db.go
57
db/db.go
@@ -1,33 +1,70 @@
|
||||
// Package db handles SQLite database setup, pragma configuration, and
|
||||
// goose migrations.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
var MigrationFS embed.FS
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Init(dbPath string) error {
|
||||
func Init(dbPath string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
|
||||
// busy_timeout must be first because the connection needs to block on
|
||||
// busy before WAL mode is set in case it hasn't been set already.
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
DB, err = goose.OpenDBWithDriver("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
DB.SetMaxOpenConns(1)
|
||||
|
||||
goose.SetBaseFS(migrations)
|
||||
if err := DB.Ping(); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close())
|
||||
}
|
||||
slog.Info("db connected", "db", dbPath)
|
||||
|
||||
sub, err := fs.Sub(MigrationFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("migrations sub fs: %w", err), DB.Close())
|
||||
}
|
||||
goose.SetBaseFS(sub)
|
||||
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
return nil, errors.Join(fmt.Errorf("setting goose dialect: %w", err), DB.Close())
|
||||
}
|
||||
if err := goose.Up(DB, "migrations"); err != nil {
|
||||
return err
|
||||
if err := goose.Up(DB, "."); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("running migrations: %w", err), DB.Close())
|
||||
}
|
||||
|
||||
return nil
|
||||
if _, err := DB.Exec("PRAGMA optimize"); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("pragma optimize: %w", err), DB.Close())
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
if _, err := DB.Exec("PRAGMA optimize(0x10002)"); err != nil {
|
||||
slog.Error("pragma optimize at shutdown", "error", err)
|
||||
}
|
||||
if err := DB.Close(); err != nil {
|
||||
slog.Error("closing database", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanup, nil
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int64
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
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;
|
||||
327
db/persister.go
327
db/persister.go
@@ -1,327 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
)
|
||||
|
||||
type GamePersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewGamePersister(q *gen.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, gen.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, gen.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, gen.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 *gen.Queries
|
||||
}
|
||||
|
||||
func NewSnakePersister(q *gen.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, gen.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, gen.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, gen.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 *gen.Queries
|
||||
}
|
||||
|
||||
func NewChatPersister(q *gen.Queries) *ChatPersister {
|
||||
return &ChatPersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *ChatPersister) SaveChatMessage(gameID string, msg ui.C4ChatMessage) error {
|
||||
return p.queries.CreateChatMessage(context.Background(), gen.CreateChatMessageParams{
|
||||
GameID: gameID,
|
||||
Nickname: msg.Nickname,
|
||||
Color: int64(msg.Color),
|
||||
Message: msg.Message,
|
||||
CreatedAt: msg.Time,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *ChatPersister) LoadChatMessages(gameID string) ([]ui.C4ChatMessage, error) {
|
||||
rows, err := p.queries.GetChatMessages(context.Background(), gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs := make([]ui.C4ChatMessage, len(rows))
|
||||
for i, r := range rows {
|
||||
msgs[i] = ui.C4ChatMessage{
|
||||
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';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// sqlc v1.30.0
|
||||
// source: chat.sql
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -3,67 +3,25 @@
|
||||
// sqlc v1.30.0
|
||||
// source: games.sql
|
||||
|
||||
package gen
|
||||
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
|
||||
}
|
||||
60
db/repository/models.go
Normal file
60
db/repository/models.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
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 `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 `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 `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"`
|
||||
}
|
||||
@@ -3,73 +3,25 @@
|
||||
// sqlc v1.30.0
|
||||
// source: snake_games.sql
|
||||
|
||||
package gen
|
||||
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
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// sqlc v1.30.0
|
||||
// source: users.sql
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,5 +5,9 @@ sql:
|
||||
schema: "migrations"
|
||||
gen:
|
||||
go:
|
||||
package: "gen"
|
||||
out: "gen"
|
||||
package: "repository"
|
||||
out: "repository"
|
||||
emit_db_tags: true
|
||||
emit_json_tags: true
|
||||
emit_result_struct_pointers: true
|
||||
emit_pointers_for_null_types: true
|
||||
|
||||
@@ -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,13 @@
|
||||
services:
|
||||
c4:
|
||||
build: .
|
||||
container_name: c4
|
||||
games:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
COMMIT: ${COMMIT:-unknown}
|
||||
secrets:
|
||||
- vendor_token
|
||||
container_name: games
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -11,4 +17,8 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data:/data
|
||||
|
||||
secrets:
|
||||
vendor_token:
|
||||
environment: VENDOR_TOKEN
|
||||
|
||||
124
features/auth/handlers.go
Normal file
124
features/auth/handlers.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ryanhamamura/games/auth"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/features/auth/pages"
|
||||
appsessions "github.com/ryanhamamura/games/sessions"
|
||||
)
|
||||
|
||||
func HandleLoginPage(sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRegisterPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1024)
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
user, err := queries.GetUserByUsername(r.Context(), username)
|
||||
if err == sql.ErrNoRows {
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid username or password"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login?error="+url.QueryEscape("An error occurred"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
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(), appsessions.KeyUserID, user.ID)
|
||||
sessions.Put(r.Context(), "username", user.Username)
|
||||
sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
|
||||
|
||||
redirectURL := "/"
|
||||
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
|
||||
sessions.Put(r.Context(), "return_url", "")
|
||||
redirectURL = returnURL
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
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: username,
|
||||
PasswordHash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/register?error="+url.QueryEscape("Username already taken"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
sessions.RenewToken(r.Context()) //nolint:errcheck
|
||||
sessions.Put(r.Context(), appsessions.KeyUserID, user.ID)
|
||||
sessions.Put(r.Context(), "username", user.Username)
|
||||
sessions.Put(r.Context(), appsessions.KeyNickname, user.Username)
|
||||
|
||||
redirectURL := "/"
|
||||
if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" {
|
||||
sessions.Put(r.Context(), "return_url", "")
|
||||
redirectURL = returnURL
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
||||
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>
|
||||
}
|
||||
}
|
||||
16
features/auth/routes.go
Normal file
16
features/auth/routes.go
Normal file
@@ -0,0 +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/games/db/repository"
|
||||
)
|
||||
|
||||
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
|
||||
router.Get("/login", HandleLoginPage(sessions))
|
||||
router.Get("/register", HandleRegisterPage())
|
||||
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
|
||||
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
|
||||
}
|
||||
306
features/c4game/handlers.go
Normal file
306
features/c4game/handlers.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package c4game
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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 *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
p := &connect4.Player{
|
||||
ID: playerID,
|
||||
Nickname: nickname,
|
||||
}
|
||||
if userID != "" {
|
||||
p.UserID = &userID
|
||||
}
|
||||
gi.Join(&connect4.PlayerSession{Player: p})
|
||||
}
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
if myColor == 0 {
|
||||
// Player not in game
|
||||
isGuest := r.URL.Query().Get("guest") == "1"
|
||||
if userID == "" && !isGuest {
|
||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
g := gi.GetGame()
|
||||
room := svc.ChatRoom(gameID)
|
||||
|
||||
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 *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
|
||||
// Subscribe to game state updates BEFORE creating SSE
|
||||
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||
|
||||
// Subscribe to chat messages BEFORE creating SSE
|
||||
chatCfg := svc.ChatConfig(gameID)
|
||||
room := svc.ChatRoom(gameID)
|
||||
chatCh, cleanupChat := room.Subscribe()
|
||||
defer cleanupChat()
|
||||
|
||||
// Setup heartbeat BEFORE creating SSE
|
||||
heartbeat := time.NewTicker(1 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
// NOW create SSE
|
||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||
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
|
||||
}
|
||||
|
||||
// Event loop
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-gameCh:
|
||||
// Drain rapid-fire notifications
|
||||
drainGame:
|
||||
for {
|
||||
select {
|
||||
case <-gameCh:
|
||||
default:
|
||||
break drainGame
|
||||
}
|
||||
}
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case chatMsg := <-chatCh:
|
||||
if err := sse.PatchElementTempl(
|
||||
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
||||
datastar.WithSelectorID("c4-chat-history"),
|
||||
datastar.WithModeAppend(),
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-heartbeat.C:
|
||||
// Heartbeat refreshes game state to keep connection alive
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
colStr := r.URL.Query().Get("col")
|
||||
col, err := strconv.Atoi(colStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid column", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
if myColor == 0 {
|
||||
http.Error(w, "not in game", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
gi.DropPiece(col, myColor)
|
||||
datastar.NewSSE(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
type ChatSignals struct {
|
||||
ChatMsg string `json:"chatMsg"`
|
||||
}
|
||||
var signals ChatSignals
|
||||
if err := datastar.ReadSignals(r, &signals); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signals.ChatMsg == "" {
|
||||
datastar.NewSSE(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
if myColor == 0 {
|
||||
datastar.NewSSE(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
g := gi.GetGame()
|
||||
nick := ""
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.ID == playerID {
|
||||
nick = p.Nickname
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Map color (1-based) to slot (0-based) for the unified chat message
|
||||
msg := chat.Message{
|
||||
Nickname: nick,
|
||||
Slot: myColor - 1,
|
||||
Message: signals.ChatMsg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
room := svc.ChatRoom(gameID)
|
||||
room.Send(msg)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.Redirect("/") //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
type NicknameSignals struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
var signals NicknameSignals
|
||||
if err := datastar.ReadSignals(r, &signals); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signals.Nickname == "" {
|
||||
datastar.NewSSE(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
userID := sessions.GetUserID(sm, r)
|
||||
|
||||
if gi.GetPlayerColor(playerID) == 0 {
|
||||
p := &connect4.Player{
|
||||
ID: playerID,
|
||||
Nickname: signals.Nickname,
|
||||
}
|
||||
if userID != "" {
|
||||
p.UserID = &userID
|
||||
}
|
||||
gi.Join(&connect4.PlayerSession{Player: p})
|
||||
}
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.Redirect("/games/" + gameID) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.Redirect("/") //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
newGI := gi.CreateRematch(store)
|
||||
sse := datastar.NewSSE(w, r)
|
||||
if newGI != nil {
|
||||
sse.Redirectf("/games/%s", newGI.ID()) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
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" class="flex flex-col items-center gap-4">
|
||||
@sharedcomponents.LiveClock()
|
||||
@sharedcomponents.BackToLobby()
|
||||
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
||||
@components.PlayerInfo(g, myColor)
|
||||
@components.StatusBanner(g, myColor)
|
||||
<div class="c4-game-area">
|
||||
@components.Board(g, myColor)
|
||||
@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")
|
||||
}
|
||||
}
|
||||
26
features/c4game/routes.go
Normal file
26
features/c4game/routes.go
Normal file
@@ -0,0 +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/ryanhamamura/games/connect4"
|
||||
"github.com/ryanhamamura/games/features/c4game/services"
|
||||
)
|
||||
|
||||
func SetupRoutes(
|
||||
router chi.Router,
|
||||
store *connect4.Store,
|
||||
svc *services.GameService,
|
||||
sessions *scs.SessionManager,
|
||||
) {
|
||||
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, svc, sessions))
|
||||
r.Post("/join", HandleSetNickname(store, sessions))
|
||||
r.Post("/rematch", HandleRematch(store, sessions))
|
||||
})
|
||||
}
|
||||
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>
|
||||
}
|
||||
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/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>
|
||||
}
|
||||
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)
|
||||
}
|
||||
12
features/lobby/components/types.go
Normal file
12
features/lobby/components/types.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package components
|
||||
|
||||
import "time"
|
||||
|
||||
// GameListItem represents a connect4 game in the user's active game list.
|
||||
type GameListItem struct {
|
||||
ID string
|
||||
Status int
|
||||
OpponentName string
|
||||
IsMyTurn bool
|
||||
LastPlayed time.Time
|
||||
}
|
||||
176
features/lobby/handlers.go
Normal file
176
features/lobby/handlers.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
// 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(), appsessions.KeyUserID)
|
||||
username := sessions.GetString(r.Context(), "username")
|
||||
isLoggedIn := userID != ""
|
||||
|
||||
var userGames []lobbycomponents.GameListItem
|
||||
if isLoggedIn {
|
||||
ctx := context.Background()
|
||||
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: opponentName,
|
||||
IsMyTurn: isMyTurn,
|
||||
LastPlayed: lastPlayed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var activeSnakeGames []pages.SnakeGameListItem
|
||||
for _, g := range snakeStore.ActiveGames() {
|
||||
statusLabel := "Waiting"
|
||||
if g.Status == snake.StatusCountdown {
|
||||
statusLabel = "Starting soon"
|
||||
}
|
||||
activeSnakeGames = append(activeSnakeGames, pages.SnakeGameListItem{
|
||||
ID: g.ID,
|
||||
Width: g.State.Width,
|
||||
Height: g.State.Height,
|
||||
PlayerCount: g.PlayerCount(),
|
||||
StatusLabel: statusLabel,
|
||||
})
|
||||
}
|
||||
|
||||
data := pages.LobbyData{
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
UserGames: userGames,
|
||||
ActiveSnakeGames: activeSnakeGames,
|
||||
}
|
||||
|
||||
if err := pages.LobbyPage(data).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE.
|
||||
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"`
|
||||
}
|
||||
signals := &Signals{}
|
||||
if err := datastar.ReadSignals(r, signals); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signals.Nickname == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
|
||||
|
||||
gi := store.Create()
|
||||
sse := datastar.NewSSE(w, r)
|
||||
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 *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
if gameID == "" {
|
||||
http.Error(w, "missing game id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
store.Delete(gameID) //nolint:errcheck
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.ExecuteScript("window.location.href='/'") //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateSnakeGame reads nickname, grid preset, speed, and mode from the request,
|
||||
// creates a snake game, and redirects via SSE.
|
||||
func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
type Signals struct {
|
||||
Nickname string `json:"nickname"`
|
||||
SelectedSpeed int `json:"selectedSpeed"`
|
||||
}
|
||||
signals := &Signals{}
|
||||
if err := datastar.ReadSignals(r, signals); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signals.Nickname == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname)
|
||||
|
||||
mode := snake.ModeMultiplayer
|
||||
if r.URL.Query().Get("mode") == "solo" {
|
||||
mode = snake.ModeSinglePlayer
|
||||
}
|
||||
|
||||
presetIdx, _ := strconv.Atoi(r.URL.Query().Get("preset"))
|
||||
if presetIdx < 0 || presetIdx >= len(snake.GridPresets) {
|
||||
presetIdx = 0
|
||||
}
|
||||
preset := snake.GridPresets[presetIdx]
|
||||
|
||||
speed := snake.DefaultSpeed
|
||||
if signals.SelectedSpeed >= 0 && signals.SelectedSpeed < len(snake.SpeedPresets) {
|
||||
speed = snake.SpeedPresets[signals.SelectedSpeed].Speed
|
||||
}
|
||||
|
||||
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())) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogout clears the session and redirects to the lobby.
|
||||
func HandleLogout(sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := sessions.Destroy(r.Context()); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
||||
20
features/lobby/pages/types.go
Normal file
20
features/lobby/pages/types.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package pages
|
||||
|
||||
import "github.com/ryanhamamura/games/features/lobby/components"
|
||||
|
||||
// SnakeGameListItem represents a joinable snake game in the lobby.
|
||||
type SnakeGameListItem struct {
|
||||
ID string
|
||||
Width int
|
||||
Height int
|
||||
PlayerCount int
|
||||
StatusLabel string
|
||||
}
|
||||
|
||||
// LobbyData holds all data needed to render the lobby page.
|
||||
type LobbyData struct {
|
||||
IsLoggedIn bool
|
||||
Username string
|
||||
UserGames []components.GameListItem
|
||||
ActiveSnakeGames []SnakeGameListItem
|
||||
}
|
||||
26
features/lobby/routes.go
Normal file
26
features/lobby/routes.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Package lobby handles the game lobby page, game creation, and navigation.
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
func SetupRoutes(
|
||||
router chi.Router,
|
||||
queries *repository.Queries,
|
||||
sessions *scs.SessionManager,
|
||||
store *connect4.Store,
|
||||
snakeStore *snake.SnakeStore,
|
||||
) {
|
||||
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))
|
||||
|
||||
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"
|
||||
}
|
||||
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)
|
||||
}
|
||||
312
features/snakegame/handlers.go
Normal file
312
features/snakegame/handlers.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package snakegame
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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 HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
p := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: nickname,
|
||||
}
|
||||
if userID != "" {
|
||||
p.UserID = &userID
|
||||
}
|
||||
si.Join(p)
|
||||
}
|
||||
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
if mySlot < 0 {
|
||||
isGuest := r.URL.Query().Get("guest") == "1"
|
||||
if userID == "" && !isGuest {
|
||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
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, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
// Subscribe to game updates BEFORE creating SSE (following portigo pattern)
|
||||
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||
|
||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||
))
|
||||
|
||||
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 chat.Message
|
||||
var cleanupChat func()
|
||||
|
||||
if room != nil {
|
||||
chatCh, cleanupChat = room.Subscribe()
|
||||
defer cleanupChat()
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
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 {
|
||||
select {
|
||||
case <-gameCh:
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case chatMsg, ok := <-chatCh:
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
err := sse.PatchElementTempl(
|
||||
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
||||
datastar.WithSelectorID("snake-chat-history"),
|
||||
datastar.WithModeAppend(),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
http.Error(w, "not in game", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
dStr := r.URL.Query().Get("d")
|
||||
d, err := strconv.Atoi(dStr)
|
||||
if err != nil || d < 0 || d > 3 {
|
||||
http.Error(w, "invalid direction", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
si.SetDirection(slot, snake.Direction(d))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
type chatSignals struct {
|
||||
ChatMsg string `json:"chatMsg"`
|
||||
}
|
||||
|
||||
func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signals chatSignals
|
||||
if err := datastar.ReadSignals(r, &signals); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signals.ChatMsg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
http.Error(w, "not in game", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
msg := chat.Message{
|
||||
Nickname: sg.Players[slot].Nickname,
|
||||
Slot: slot,
|
||||
Message: signals.ChatMsg,
|
||||
}
|
||||
|
||||
room := svc.ChatRoom(gameID)
|
||||
room.Send(msg)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
type nicknameSignals struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signals nicknameSignals
|
||||
if err := datastar.ReadSignals(r, &signals); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if signals.Nickname == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname)
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
userID := sessions.GetUserID(sm, r)
|
||||
|
||||
if si.GetPlayerSlot(playerID) < 0 {
|
||||
p := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: signals.Nickname,
|
||||
}
|
||||
if userID != "" {
|
||||
p.UserID = &userID
|
||||
}
|
||||
si.Join(p)
|
||||
}
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.Redirect("/snake/" + gameID) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
newSI := si.CreateRematch()
|
||||
sse := datastar.NewSSE(w, r)
|
||||
if newSI != nil {
|
||||
sse.Redirect("/snake/" + newSI.ID()) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
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" class="flex flex-col items-center gap-4">
|
||||
@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))
|
||||
}
|
||||
}
|
||||
21
features/snakegame/routes.go
Normal file
21
features/snakegame/routes.go
Normal file
@@ -0,0 +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/ryanhamamura/games/features/snakegame/services"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
)
|
||||
|
||||
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, svc, sessions))
|
||||
r.Post("/join", HandleSetNickname(snakeStore, sessions))
|
||||
r.Post("/rematch", HandleRematch(snakeStore, sessions))
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
243
game/store.go
243
game/store.go
@@ -1,243 +0,0 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
}
|
||||
|
||||
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
|
||||
pubsub PubSub
|
||||
}
|
||||
|
||||
func NewGameStore() *GameStore {
|
||||
return &GameStore{
|
||||
games: make(map[string]*GameInstance),
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPersister(p Persister) {
|
||||
gs.persister = p
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPubSub(ps PubSub) {
|
||||
gs.pubsub = ps
|
||||
}
|
||||
|
||||
func (gs *GameStore) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if gs.pubsub != nil {
|
||||
gs.pubsub.Publish("game."+gameID, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
240
go.mod
240
go.mod
@@ -1,52 +1,250 @@
|
||||
module github.com/ryanhamamura/c4
|
||||
module github.com/ryanhamamura/games
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.1001
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/delaneyj/toolbelt v0.9.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/ryanhamamura/via v0.21.2
|
||||
golang.org/x/crypto v0.47.0
|
||||
modernc.org/sqlite v1.44.0
|
||||
github.com/nats-io/nats-server/v2 v2.12.2
|
||||
github.com/nats-io/nats.go v1.48.0
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/starfederation/datastar-go v1.1.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1 // indirect
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/storage v1.58.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
|
||||
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||
github.com/Ladicle/tabwriter v1.0.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
|
||||
github.com/aws/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
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/delaneyj/toolbelt v0.9.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chainguard-dev/git-urls v1.0.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.5.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cubicdaiya/gonp v1.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dominikbraun/graph v0.23.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-task/task/v3 v3.48.0 // indirect
|
||||
github.com/go-task/template v0.2.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/cel-go v0.26.1 // indirect
|
||||
github.com/google/go-tpm v0.9.7 // indirect
|
||||
github.com/hookenz/gotailwind/v4 v4.1.18 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-getter v1.8.4 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hookenz/gotailwind/v4 v4.2.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mfridman/xflag v0.1.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.6 // indirect
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/natefinch/atomic v1.0.1 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.0 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.12.2 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
|
||||
github.com/pingcap/log v1.1.0 // indirect
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
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/starfederation/datastar-go v1.0.3 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/sqlc-dev/sqlc v1.30.0 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc // indirect
|
||||
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8 // indirect
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vertica/vertica-sql-go v1.3.5 // indirect
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 // indirect
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
maragu.dev/gomponents v1.2.0 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/api v0.256.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 // indirect
|
||||
mvdan.cc/sh/v3 v3.12.1-0.20260124232039-e74afc18e65b // indirect
|
||||
)
|
||||
|
||||
tool github.com/hookenz/gotailwind/v4
|
||||
tool (
|
||||
github.com/a-h/templ/cmd/templ
|
||||
github.com/air-verse/air
|
||||
github.com/go-task/task/v3/cmd/task
|
||||
github.com/hookenz/gotailwind/v4
|
||||
github.com/pressly/goose/v3/cmd/goose
|
||||
github.com/sqlc-dev/sqlc/cmd/sqlc
|
||||
)
|
||||
|
||||
815
go.sum
815
go.sum
@@ -1,5 +1,82 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD7k=
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 h1:059k1h5vvZ4ASinki9nmBguxu9Rq0UDDSa6q8LOUphk=
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
|
||||
cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
|
||||
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg=
|
||||
github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.23.0 h1:u/Orux1J0eLuZDeQ44froV8smumheieI0EofhbyKhhk=
|
||||
github.com/alecthomas/chroma/v2 v2.23.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
@@ -7,51 +84,405 @@ github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
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=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
|
||||
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
|
||||
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
|
||||
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
|
||||
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
|
||||
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
|
||||
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
|
||||
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
|
||||
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
|
||||
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
|
||||
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
|
||||
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
|
||||
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
|
||||
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
|
||||
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
|
||||
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
|
||||
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
|
||||
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
|
||||
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
|
||||
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
|
||||
github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
|
||||
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
|
||||
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
|
||||
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ=
|
||||
github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o=
|
||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc=
|
||||
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
|
||||
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
|
||||
github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
|
||||
github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/delaneyj/toolbelt v0.9.1 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o=
|
||||
github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
|
||||
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM=
|
||||
github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q=
|
||||
github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
|
||||
github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU=
|
||||
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
|
||||
github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
|
||||
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-task/task/v3 v3.48.0 h1:HEim5OOpgmob5ONfq7ji3QHUyJdcwqL5ctOT5CPWCzA=
|
||||
github.com/go-task/task/v3 v3.48.0/go.mod h1:ChDoJV0k919miEJJu1yJ846tg+4Ivv9ZE/1YwQXvIRY=
|
||||
github.com/go-task/template v0.2.0 h1:xW7ek0o65FUSTbKcSNeg2Vyf/I7wYXFgLUznptvviBE=
|
||||
github.com/go-task/template v0.2.0/go.mod h1:dbdoUb6qKnHQi1y6o+IdIrs0J4o/SEhSTA6bbzZmdtc=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
|
||||
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
|
||||
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
|
||||
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
|
||||
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
|
||||
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
|
||||
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
|
||||
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
||||
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
|
||||
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 h1:0HADrxxqaQkGycO1JoUUA+B4FnIkuo8d2bz/hSaTFFQ=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70/go.mod h1:fm2FdDCzJdtbXF7WKAMvBb5NEPouXPHFbGNYs9ShFns=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-getter v1.8.4 h1:hGEd2xsuVKgwkMtPVufq73fAmZU/x65PPcqH3cb0D9A=
|
||||
github.com/hashicorp/go-getter v1.8.4/go.mod h1:x27pPGSg9kzoB147QXI8d/nDvp2IgYGcwuRjpaXE9Yg=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE=
|
||||
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hookenz/gotailwind/v4 v4.2.1 h1:FpZLtAAbHH7wMvyGYT+01vTLFITGMGZGMtEbp7dd2dM=
|
||||
github.com/hookenz/gotailwind/v4 v4.2.1/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
|
||||
github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
|
||||
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
|
||||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
|
||||
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
|
||||
github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE=
|
||||
@@ -64,81 +495,389 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
|
||||
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
|
||||
github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
|
||||
github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM=
|
||||
github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
|
||||
github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/ryanhamamura/via v0.21.2 h1:osR6peY/mZSl9SNPeEv6IvzGU0akkQfQzQJgA74+7mk=
|
||||
github.com/ryanhamamura/via v0.21.2/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
|
||||
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=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
|
||||
github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
|
||||
github.com/starfederation/datastar-go v1.1.0 h1:UVOYpbNfKPfrEq3MBOa1FRPO/YsxxcIduUxUTJiEQbQ=
|
||||
github.com/starfederation/datastar-go v1.1.0/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
|
||||
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
|
||||
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
|
||||
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
|
||||
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
|
||||
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
|
||||
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8 h1:cq+DjLAjz3ZPwh0+G571O/jMH0c0DzReDPLjQGL2/BA=
|
||||
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8/go.mod h1:JNauIV2zopCBv/6o+umxcT3bKe8YUqYJaTZQYSYpKss=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
|
||||
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||
github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI=
|
||||
github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 h1:kUXMT/fM/DpDT66WQgRUf3I8VOAWjypkMf52W5PChwA=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 h1:OfHS9ZkZgCy6y/CJ9N8123DXrgaY2BPxWsQiQ8e3wC8=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc=
|
||||
maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -147,9 +886,15 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.0 h1:YjCKJnzZde2mLVy0cMKTSL4PxCmbIguOq9lGp8ZvGOc=
|
||||
modernc.org/sqlite v1.44.0/go.mod h1:2Dq41ir5/qri7QJJJKNZcP4UF7TsX/KNeykYgPDtGhE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR3eLHTH1CmR3BcU6TriIMiXjE=
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997/go.mod h1:Qy/zdaMDxq9sT72Gi43K3gsV+TtTohyDO3f1cyBVwuo=
|
||||
mvdan.cc/sh/v3 v3.12.1-0.20260124232039-e74afc18e65b h1:PUPnLxbDzRO9kg/03l7TZk7+ywTv7FxmOhDHOtOdOtk=
|
||||
mvdan.cc/sh/v3 v3.12.1-0.20260124232039-e74afc18e65b/go.mod h1:mencVHx2sy9XZG5wJbCA9nRUOE3zvMtoRXOmXMxH7sc=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
41
logging/log.go
Normal file
41
logging/log.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Package logging configures zerolog and provides HTTP request logging middleware.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
stdlog "log"
|
||||
"os"
|
||||
|
||||
"github.com/ryanhamamura/games/config"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
func SetupLogger(env config.Environment, level zerolog.Level) *zerolog.Logger {
|
||||
zerolog.ErrorStackFieldName = "stack_trace"
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
|
||||
zerolog.SetGlobalLevel(level)
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
|
||||
var output io.Writer
|
||||
switch env {
|
||||
case config.Dev:
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006/01/02 15:04:05",
|
||||
}
|
||||
case config.Prod:
|
||||
output = os.Stderr
|
||||
}
|
||||
|
||||
logger := zerolog.New(output).With().Timestamp().Stack().Logger()
|
||||
zerolog.DefaultContextLogger = &logger
|
||||
log.Logger = logger
|
||||
|
||||
stdlog.SetFlags(0)
|
||||
stdlog.SetOutput(logger)
|
||||
return &logger
|
||||
}
|
||||
117
logging/middleware.go
Normal file
117
logging/middleware.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/ryanhamamura/games/config"
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBrightRed = "\033[31;1m"
|
||||
ansiBrightGreen = "\033[32;1m"
|
||||
ansiBrightYellow = "\033[33;1m"
|
||||
ansiBrightMagenta = "\033[35;1m"
|
||||
ansiBrightCyan = "\033[36;1m"
|
||||
ansiGreen = "\033[32m"
|
||||
ansiYellow = "\033[33m"
|
||||
ansiRed = "\033[31m"
|
||||
)
|
||||
|
||||
func colorStatus(status int, useColor bool) string {
|
||||
s := fmt.Sprintf("%d", status)
|
||||
if !useColor {
|
||||
return s
|
||||
}
|
||||
switch {
|
||||
case status < 200:
|
||||
return ansiBrightGreen + s + ansiReset
|
||||
case status < 300:
|
||||
return ansiBrightGreen + s + ansiReset
|
||||
case status < 400:
|
||||
return ansiBrightCyan + s + ansiReset
|
||||
case status < 500:
|
||||
return ansiBrightYellow + s + ansiReset
|
||||
default:
|
||||
return ansiBrightRed + s + ansiReset
|
||||
}
|
||||
}
|
||||
|
||||
func colorMethod(method string, useColor bool) string {
|
||||
if !useColor {
|
||||
return method
|
||||
}
|
||||
return ansiBrightMagenta + method + ansiReset
|
||||
}
|
||||
|
||||
func colorLatency(d time.Duration, useColor bool) string {
|
||||
s := d.String()
|
||||
if !useColor {
|
||||
return s
|
||||
}
|
||||
switch {
|
||||
case d < 500*time.Millisecond:
|
||||
return ansiGreen + s + ansiReset
|
||||
case d < 5*time.Second:
|
||||
return ansiYellow + s + ansiReset
|
||||
default:
|
||||
return ansiRed + s + ansiReset
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
status := ww.Status()
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
l := log.Ctx(r.Context())
|
||||
if l.GetLevel() == zerolog.Disabled {
|
||||
l = logger
|
||||
}
|
||||
|
||||
var evt *zerolog.Event
|
||||
switch {
|
||||
case status < 400:
|
||||
evt = l.Info()
|
||||
case status < 500:
|
||||
evt = l.Warn()
|
||||
case status < 600:
|
||||
evt = l.Error()
|
||||
default:
|
||||
evt = l.Info()
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
switch env {
|
||||
case config.Dev:
|
||||
useColor := true
|
||||
evt.Msg(fmt.Sprintf("%s %s %s [%s]",
|
||||
colorStatus(status, useColor),
|
||||
colorMethod(r.Method, useColor),
|
||||
r.URL.Path,
|
||||
colorLatency(latency, useColor),
|
||||
))
|
||||
default:
|
||||
evt.
|
||||
Int("status", status).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Dur("latency", latency).
|
||||
Msg("request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
853
main.go
853
main.go
@@ -2,789 +2,128 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
slogzerolog "github.com/samber/slog-zerolog/v2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
store = game.NewGameStore()
|
||||
snakeStore = snake.NewSnakeStore()
|
||||
queries *gen.Queries
|
||||
chatPersister *db.ChatPersister
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
func DaisyUIPlugin(v *via.V) {
|
||||
css, _ := fs.ReadFile(assets, "assets/css/output.css")
|
||||
sum := md5.Sum(css)
|
||||
version := hex.EncodeToString(sum[:4])
|
||||
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version)))
|
||||
}
|
||||
|
||||
func port() string {
|
||||
if p := os.Getenv("PORT"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "7331"
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := db.Init("data/c4.db"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
queries = gen.New(db.DB)
|
||||
store.SetPersister(db.NewGamePersister(queries))
|
||||
snakeStore.SetPersister(db.NewSnakePersister(queries))
|
||||
chatPersister = db.NewChatPersister(queries)
|
||||
cfg := config.Global
|
||||
zerologLogger := logging.SetupLogger(cfg.Environment, cfg.LogLevel)
|
||||
slog.SetDefault(slog.New(slogzerolog.Option{
|
||||
Level: slogzerolog.ZeroLogLeveler{Logger: zerologLogger},
|
||||
Logger: zerologLogger,
|
||||
NoTimestamp: true,
|
||||
}.NewZerologHandler()))
|
||||
|
||||
sessionManager, err := via.NewSQLiteSessionManager(db.DB)
|
||||
if err := run(ctx); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal().Err(err).Msg("server error")
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
cfg := config.Global
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||
slog.Info("server starting", "addr", addr, "version", version.Version, "commit", version.Commit)
|
||||
defer slog.Info("server shutdown complete")
|
||||
|
||||
eg, egctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Database
|
||||
cleanupDB, err := db.Init(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return fmt.Errorf("initializing database: %w", err)
|
||||
}
|
||||
defer cleanupDB()
|
||||
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
LogLevel: via.LogLevelDebug,
|
||||
DocumentTitle: "Game Lobby",
|
||||
ServerAddress: ":" + port(),
|
||||
SessionManager: sessionManager,
|
||||
Plugins: []via.Plugin{DaisyUIPlugin},
|
||||
ContextSuspendAfter: 5 * time.Minute,
|
||||
ContextTTL: 30 * time.Minute,
|
||||
})
|
||||
queries := repository.New(db.DB)
|
||||
|
||||
subFS, _ := fs.Sub(assets, "assets")
|
||||
v.StaticFS("/assets/", subFS)
|
||||
// Sessions
|
||||
sessionManager, cleanupSessions := sessions.SetupSessionManager(db.DB)
|
||||
defer cleanupSessions()
|
||||
|
||||
store.SetPubSub(v.PubSub())
|
||||
snakeStore.SetPubSub(v.PubSub())
|
||||
|
||||
// Home page - tabbed lobby
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := c.Session().GetString("user_id")
|
||||
username := c.Session().GetString("username")
|
||||
isLoggedIn := userID != ""
|
||||
|
||||
var userGames []ui.GameListItem
|
||||
if isLoggedIn {
|
||||
ctx := context.Background()
|
||||
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true})
|
||||
if err == nil {
|
||||
for _, g := range games {
|
||||
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
|
||||
userGames = append(userGames, ui.GameListItem{
|
||||
ID: g.ID,
|
||||
Status: int(g.Status),
|
||||
OpponentName: g.OpponentNickname.String,
|
||||
IsMyTurn: isMyTurn,
|
||||
LastPlayed: g.UpdatedAt.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
nickname = c.Signal(username)
|
||||
}
|
||||
activeTab := c.Signal("connect4")
|
||||
|
||||
logout := c.Action(func() {
|
||||
c.Session().Clear()
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
createGame := c.Action(func() {
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
gi := store.Create()
|
||||
c.Redirectf("/game/%s", gi.ID())
|
||||
})
|
||||
|
||||
deleteGame := func(id string) h.H {
|
||||
return c.Action(func() {
|
||||
for _, g := range userGames {
|
||||
if g.ID == id {
|
||||
store.Delete(id)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Redirect("/")
|
||||
}).OnClick()
|
||||
}
|
||||
|
||||
tabClickConnect4 := c.Action(func() {
|
||||
activeTab.SetValue("connect4")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
tabClickSnake := c.Action(func() {
|
||||
activeTab.SetValue("snake")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
snakeNickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
snakeNickname = c.Signal(username)
|
||||
}
|
||||
|
||||
// Speed selection signal (index into SpeedPresets, default to Normal which is index 1)
|
||||
selectedSpeedIndex := c.Signal(1)
|
||||
|
||||
// Speed selector actions
|
||||
var speedSelectClicks []h.H
|
||||
for i := range snake.SpeedPresets {
|
||||
idx := i
|
||||
speedSelectClicks = append(speedSelectClicks, c.Action(func() {
|
||||
selectedSpeedIndex.SetValue(idx)
|
||||
c.Sync()
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
// Snake create game actions — one per preset for solo and multiplayer
|
||||
var snakeSoloClicks []h.H
|
||||
var snakeMultiClicks []h.H
|
||||
for _, preset := range snake.GridPresets {
|
||||
w, ht := preset.Width, preset.Height
|
||||
snakeSoloClicks = append(snakeSoloClicks, c.Action(func() {
|
||||
name := snakeNickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
speedIdx := selectedSpeedIndex.Int()
|
||||
speed := snake.DefaultSpeed
|
||||
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
|
||||
speed = snake.SpeedPresets[speedIdx].Speed
|
||||
}
|
||||
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed)
|
||||
c.Redirectf("/snake/%s", si.ID())
|
||||
}).OnClick())
|
||||
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
|
||||
name := snakeNickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
speedIdx := selectedSpeedIndex.Int()
|
||||
speed := snake.DefaultSpeed
|
||||
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
|
||||
speed = snake.SpeedPresets[speedIdx].Speed
|
||||
}
|
||||
si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed)
|
||||
c.Redirectf("/snake/%s", si.ID())
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LobbyView(ui.LobbyProps{
|
||||
NicknameBind: nickname.Bind(),
|
||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||
CreateGameClick: createGame.OnClick(),
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
LogoutClick: logout.OnClick(),
|
||||
UserGames: userGames,
|
||||
DeleteGameClick: deleteGame,
|
||||
ActiveTab: activeTab.String(),
|
||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||
TabClickSnake: tabClickSnake.OnClick(),
|
||||
SnakeNicknameBind: snakeNickname.Bind(),
|
||||
SnakeSoloClicks: snakeSoloClicks,
|
||||
SnakeMultiClicks: snakeMultiClicks,
|
||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||
SelectedSpeedIndex: selectedSpeedIndex.Int(),
|
||||
SpeedSelectClicks: speedSelectClicks,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Login page
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
login := c.Action(func() {
|
||||
ctx := context.Background()
|
||||
user, err := queries.GetUserByUsername(ctx, username.String())
|
||||
if err == sql.ErrNoRows {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
// NATS
|
||||
nc, cleanupNATS, err := appnats.SetupNATS(egctx)
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(password.String(), user.PasswordHash) {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
return fmt.Errorf("setting up NATS: %w", err)
|
||||
}
|
||||
defer cleanupNATS()
|
||||
|
||||
c.Session().RenewToken()
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
|
||||
returnURL := c.Session().GetString("return_url")
|
||||
if returnURL != "" {
|
||||
c.Session().Set("return_url", "")
|
||||
c.Redirect(returnURL)
|
||||
} else {
|
||||
c.Redirect("/")
|
||||
}
|
||||
// Game stores
|
||||
store := connect4.NewStore(queries)
|
||||
store.SetNotifyFunc(func(gameID string) {
|
||||
nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LoginView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
login.OnKeyDown("Enter"),
|
||||
login.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
snakeStore := snake.NewSnakeStore(queries)
|
||||
snakeStore.SetNotifyFunc(func(gameID string) {
|
||||
nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
|
||||
})
|
||||
|
||||
// Register page
|
||||
v.Page("/register", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
confirm := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
register := c.Action(func() {
|
||||
if err := auth.ValidateUsername(username.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(password.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if password.String() != confirm.String() {
|
||||
errorMsg.SetValue("Passwords do not match")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password.String())
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
id := uuid.New().String()
|
||||
user, err := queries.CreateUser(ctx, gen.CreateUserParams{
|
||||
ID: id,
|
||||
Username: username.String(),
|
||||
PasswordHash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
errorMsg.SetValue("Username already taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().RenewToken()
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
|
||||
returnURL := c.Session().GetString("return_url")
|
||||
if returnURL != "" {
|
||||
c.Session().Set("return_url", "")
|
||||
c.Redirect(returnURL)
|
||||
} else {
|
||||
c.Redirect("/")
|
||||
}
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.RegisterView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
confirm.Bind(),
|
||||
register.OnKeyDown("Enter"),
|
||||
register.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Connect 4 game page
|
||||
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
colSignal := c.Signal(0)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
chatMsg := c.Signal("")
|
||||
chatMessages, _ := chatPersister.LoadChatMessages(gameID)
|
||||
var chatMu sync.Mutex
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/game/"+gameID)
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
goToRegister := c.Action(func() {
|
||||
c.Session().Set("return_url", "/game/"+gameID)
|
||||
c.Redirect("/register")
|
||||
})
|
||||
|
||||
continueAsGuest := c.Action(func() {
|
||||
showGuestPrompt.SetValue(true)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
var gi *game.GameInstance
|
||||
var gameExists bool
|
||||
|
||||
if gameID != "" {
|
||||
gi, gameExists = store.Get(gameID)
|
||||
}
|
||||
|
||||
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
playerID = game.PlayerID(game.GenerateID(8))
|
||||
c.Session().Set("player_id", string(playerID))
|
||||
}
|
||||
|
||||
if sessionUserID != "" {
|
||||
playerID = game.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
}
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
if gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
})
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
dropPiece := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
}
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
if myColor == 0 {
|
||||
return
|
||||
}
|
||||
col := colSignal.Int()
|
||||
gi.DropPiece(col, myColor)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
createRematch := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
}
|
||||
newGI := gi.CreateRematch(store)
|
||||
if newGI != nil {
|
||||
c.Redirectf("/game/%s", newGI.ID())
|
||||
}
|
||||
})
|
||||
|
||||
sendChat := c.Action(func() {
|
||||
msg := chatMsg.String()
|
||||
if msg == "" || gi == nil {
|
||||
return
|
||||
}
|
||||
color := gi.GetPlayerColor(playerID)
|
||||
if color == 0 {
|
||||
return
|
||||
}
|
||||
g := gi.GetGame()
|
||||
nick := ""
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.ID == playerID {
|
||||
nick = p.Nickname
|
||||
break
|
||||
}
|
||||
}
|
||||
cm := ui.C4ChatMessage{
|
||||
Nickname: nick,
|
||||
Color: color,
|
||||
Message: msg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
chatPersister.SaveChatMessage(gameID, cm)
|
||||
data, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Publish("game.chat."+gameID, data)
|
||||
chatMsg.SetValue("")
|
||||
})
|
||||
|
||||
if gameExists {
|
||||
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
||||
|
||||
c.Subscribe("game.chat."+gameID, func(data []byte) {
|
||||
var cm ui.C4ChatMessage
|
||||
if err := json.Unmarshal(data, &cm); err != nil {
|
||||
return
|
||||
}
|
||||
chatMu.Lock()
|
||||
chatMessages = append(chatMessages, cm)
|
||||
if len(chatMessages) > 50 {
|
||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
||||
}
|
||||
chatMu.Unlock()
|
||||
c.Sync()
|
||||
})
|
||||
}
|
||||
|
||||
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
})
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
if myColor == 0 {
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
continueAsGuest.OnClick(),
|
||||
goToRegister.OnClick(),
|
||||
)
|
||||
}
|
||||
return ui.NicknamePrompt(
|
||||
nickname.Bind(),
|
||||
setNickname.OnKeyDown("Enter"),
|
||||
setNickname.OnClick(),
|
||||
)
|
||||
}
|
||||
|
||||
g := gi.GetGame()
|
||||
|
||||
columnClick := func(col int) h.H {
|
||||
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
||||
}
|
||||
|
||||
chatMu.Lock()
|
||||
msgs := make([]ui.C4ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
chat := ui.C4Chat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
|
||||
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
ui.BackToLobby(),
|
||||
ui.StealthTitle("text-3xl font-bold"),
|
||||
ui.PlayerInfo(g, myColor),
|
||||
ui.StatusBanner(g, myColor, createRematch.OnClick()),
|
||||
h.Div(h.Class("c4-game-area"), ui.BoardComponent(g, columnClick, myColor), chat),
|
||||
// Router
|
||||
logger := log.Logger
|
||||
r := chi.NewMux()
|
||||
r.Use(
|
||||
logging.RequestLogger(&logger, cfg.Environment),
|
||||
middleware.Recoverer,
|
||||
sessionManager.LoadAndSave,
|
||||
)
|
||||
|
||||
if g.Status == game.StatusWaitingForPlayer {
|
||||
content = append(content, ui.InviteLink(g.ID))
|
||||
}
|
||||
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
|
||||
|
||||
mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")}
|
||||
mainAttrs = append(mainAttrs, content...)
|
||||
return h.Main(mainAttrs...)
|
||||
})
|
||||
})
|
||||
|
||||
// Snake game page
|
||||
v.Page("/snake/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
goToRegister := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/register")
|
||||
})
|
||||
|
||||
continueAsGuest := c.Action(func() {
|
||||
showGuestPrompt.SetValue(true)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
var si *snake.SnakeGameInstance
|
||||
var gameExists bool
|
||||
|
||||
if gameID != "" {
|
||||
si, gameExists = snakeStore.Get(gameID)
|
||||
}
|
||||
|
||||
playerID := snake.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
pid := game.GenerateID(8)
|
||||
playerID = snake.PlayerID(pid)
|
||||
c.Session().Set("player_id", pid)
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
playerID = snake.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
if si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
// Direction input: single action with a direction signal
|
||||
dirSignal := c.Signal(-1)
|
||||
handleDir := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
return
|
||||
}
|
||||
dir := snake.Direction(dirSignal.Int())
|
||||
si.SetDirection(slot, dir)
|
||||
})
|
||||
|
||||
createRematch := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
newSI := si.CreateRematch()
|
||||
if newSI != nil {
|
||||
c.Redirectf("/snake/%s", newSI.ID())
|
||||
}
|
||||
})
|
||||
|
||||
chatMsg := c.Signal("")
|
||||
var chatMessages []ui.ChatMessage
|
||||
var chatMu sync.Mutex
|
||||
|
||||
sendChat := c.Action(func() {
|
||||
msg := chatMsg.String()
|
||||
if msg == "" || si == nil {
|
||||
return
|
||||
}
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
return
|
||||
}
|
||||
cm := ui.ChatMessage{
|
||||
Nickname: si.GetGame().Players[slot].Nickname,
|
||||
Slot: slot,
|
||||
Message: msg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Publish("snake.chat."+gameID, data)
|
||||
chatMsg.SetValue("")
|
||||
})
|
||||
|
||||
if gameExists {
|
||||
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
|
||||
|
||||
if si.GetGame().Mode == snake.ModeMultiplayer {
|
||||
c.Subscribe("snake.chat."+gameID, func(data []byte) {
|
||||
var cm ui.ChatMessage
|
||||
if err := json.Unmarshal(data, &cm); err != nil {
|
||||
return
|
||||
}
|
||||
chatMu.Lock()
|
||||
chatMessages = append(chatMessages, cm)
|
||||
if len(chatMessages) > 50 {
|
||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
||||
}
|
||||
chatMu.Unlock()
|
||||
c.Sync()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-join if nickname exists
|
||||
if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
if mySlot < 0 {
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
continueAsGuest.OnClick(),
|
||||
goToRegister.OnClick(),
|
||||
)
|
||||
}
|
||||
return ui.NicknamePrompt(
|
||||
nickname.Bind(),
|
||||
setNickname.OnKeyDown("Enter"),
|
||||
setNickname.OnClick(),
|
||||
)
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
ui.BackToLobby(),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("~~~~")),
|
||||
ui.SnakePlayerList(sg, mySlot),
|
||||
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
||||
)
|
||||
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
board := ui.SnakeBoard(sg)
|
||||
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
chatMu.Lock()
|
||||
msgs := make([]ui.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
chat := ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
|
||||
content = append(content, h.Div(h.Class("snake-game-area"), board, chat))
|
||||
} else {
|
||||
content = append(content, board)
|
||||
}
|
||||
} else if sg.Mode == snake.ModeMultiplayer {
|
||||
// Show chat even before game starts (waiting/countdown)
|
||||
chatMu.Lock()
|
||||
msgs := make([]ui.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
content = append(content, ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")))
|
||||
}
|
||||
|
||||
// Only show invite link for multiplayer games
|
||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
||||
}
|
||||
|
||||
wrapperAttrs := []h.H{
|
||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||
via.OnKeyDownMap(
|
||||
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
// HTTP server
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return egctx
|
||||
},
|
||||
ErrorLog: slog.NewLogLogger(
|
||||
slog.Default().Handler(),
|
||||
slog.LevelError,
|
||||
),
|
||||
}
|
||||
|
||||
wrapperAttrs = append(wrapperAttrs, content...)
|
||||
return h.Main(wrapperAttrs...)
|
||||
})
|
||||
eg.Go(func() error {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("server error: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
v.Start()
|
||||
eg.Go(func() error {
|
||||
<-egctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
slog.Debug("shutting down server...")
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
69
nats/nats.go
Normal file
69
nats/nats.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package nats sets up an embedded NATS server for real-time pub/sub
|
||||
// messaging between game clients.
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/delaneyj/toolbelt"
|
||||
"github.com/delaneyj/toolbelt/embeddednats"
|
||||
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func SetupNATS(ctx context.Context) (*nats.Conn, func(), error) {
|
||||
natsPort, err := getFreeNatsPort()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("obtaining NATS port: %w", err)
|
||||
}
|
||||
|
||||
ns, err := embeddednats.New(ctx, embeddednats.WithNATSServerOptions(&natsserver.Options{
|
||||
NoSigs: true,
|
||||
Port: natsPort,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating embedded nats server: %w", err)
|
||||
}
|
||||
|
||||
ns.WaitForServer()
|
||||
slog.Info("NATS started", "port", natsPort)
|
||||
|
||||
nc, err := ns.Client()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating nats client: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
nc.Close()
|
||||
ns.Close() //nolint:errcheck
|
||||
}
|
||||
|
||||
return nc, cleanup, nil
|
||||
}
|
||||
|
||||
func getFreeNatsPort() (int, error) {
|
||||
if p, ok := os.LookupEnv("NATS_PORT"); ok {
|
||||
natsPort, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing NATS_PORT: %w", err)
|
||||
}
|
||||
if isPortFree(natsPort) {
|
||||
return natsPort, nil
|
||||
}
|
||||
}
|
||||
return toolbelt.FreePort()
|
||||
}
|
||||
|
||||
func isPortFree(port int) bool {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ln.Close() //nolint:errcheck // checking port availability
|
||||
return true
|
||||
}
|
||||
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)
|
||||
}
|
||||
75
router/router.go
Normal file
75
router/router.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Package router wires feature routes and middleware into the central chi mux.
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
|
||||
"github.com/ryanhamamura/games/assets"
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/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(
|
||||
router chi.Router,
|
||||
queries *repository.Queries,
|
||||
sessions *scs.SessionManager,
|
||||
nc *nats.Conn,
|
||||
store *connect4.Store,
|
||||
snakeStore *snake.SnakeStore,
|
||||
) {
|
||||
// Static assets
|
||||
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, c4Svc, sessions)
|
||||
snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
|
||||
}
|
||||
|
||||
func setupReload(router chi.Router) {
|
||||
reloadChan := make(chan struct{}, 1)
|
||||
var hotReloadOnce sync.Once
|
||||
|
||||
router.Get("/reload", func(w http.ResponseWriter, r *http.Request) {
|
||||
sse := datastar.NewSSE(w, r)
|
||||
reload := func() { sse.ExecuteScript("window.location.reload()") } //nolint:errcheck // dev-only
|
||||
hotReloadOnce.Do(reload)
|
||||
select {
|
||||
case <-reloadChan:
|
||||
reload()
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
})
|
||||
|
||||
router.Get("/hotreload", func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case reloadChan <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK")) //nolint:errcheck // dev-only
|
||||
})
|
||||
}
|
||||
67
sessions/sessions.go
Normal file
67
sessions/sessions.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Package sessions configures the SCS session manager and provides
|
||||
// helpers for resolving player identity from the session.
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"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()) {
|
||||
store := sqlite3store.New(db)
|
||||
cleanup := func() { store.StopCleanup() }
|
||||
|
||||
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
|
||||
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
|
||||
|
||||
slog.Info("session manager configured")
|
||||
|
||||
return sessionManager, cleanup
|
||||
}
|
||||
|
||||
// GetPlayerID returns the current player's identity from the session.
|
||||
// Authenticated users get their user UUID; guests get a random ID that
|
||||
// is generated and persisted on first access.
|
||||
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
|
||||
pid := sm.GetString(r.Context(), KeyPlayerID)
|
||||
if pid == "" {
|
||||
pid = player.GenerateID(8)
|
||||
sm.Put(r.Context(), KeyPlayerID, pid)
|
||||
}
|
||||
if userID := sm.GetString(r.Context(), KeyUserID); userID != "" {
|
||||
return player.ID(userID)
|
||||
}
|
||||
return player.ID(pid)
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user's UUID, or empty string for guests.
|
||||
func GetUserID(sm *scs.SessionManager, r *http.Request) string {
|
||||
return sm.GetString(r.Context(), KeyUserID)
|
||||
}
|
||||
|
||||
// GetNickname returns the player's display name from the session.
|
||||
func GetNickname(sm *scs.SessionManager, r *http.Request) string {
|
||||
return sm.GetString(r.Context(), KeyNickname)
|
||||
}
|
||||
@@ -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,48 +1,35 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
}
|
||||
|
||||
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
|
||||
pubsub PubSub
|
||||
queries *repository.Queries
|
||||
notifyFunc func(gameID string)
|
||||
}
|
||||
|
||||
func NewSnakeStore() *SnakeStore {
|
||||
func NewSnakeStore(queries *repository.Queries) *SnakeStore {
|
||||
return &SnakeStore{
|
||||
games: make(map[string]*SnakeGameInstance),
|
||||
queries: queries,
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPersister(p Persister) {
|
||||
ss.persister = p
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPubSub(ps PubSub) {
|
||||
ss.pubsub = ps
|
||||
func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
|
||||
ss.notifyFunc = f
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if ss.pubsub != nil {
|
||||
ss.pubsub.Publish("snake."+gameID, nil)
|
||||
if ss.notifyFunc != nil {
|
||||
ss.notifyFunc(gameID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,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{
|
||||
@@ -66,7 +53,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
||||
si := &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
queries: ss.queries,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
@@ -74,8 +61,8 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
||||
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
|
||||
@@ -90,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)
|
||||
}
|
||||
@@ -112,7 +99,7 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
||||
si = &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
queries: ss.queries,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
@@ -133,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
|
||||
}
|
||||
@@ -166,7 +153,7 @@ type SnakeGameInstance struct {
|
||||
gameMu sync.RWMutex
|
||||
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
|
||||
notify func()
|
||||
persister Persister
|
||||
queries *repository.Queries
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
@@ -185,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 {
|
||||
@@ -218,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()
|
||||
@@ -305,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
|
||||
|
||||
47
testutil/db.go
Normal file
47
testutil/db.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Package testutil provides composable test helpers for spinning up
|
||||
// real infrastructure (in-memory SQLite, session managers) in
|
||||
// integration tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/games/db"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// NewTestDB opens an in-memory SQLite database with the same pragmas as
|
||||
// production, runs all goose migrations, and returns the raw connection
|
||||
// alongside the sqlc Queries handle. The database is closed automatically
|
||||
// when the test finishes.
|
||||
func NewTestDB(t *testing.T) (*sql.DB, *repository.Queries) {
|
||||
t.Helper()
|
||||
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
|
||||
database, err := goose.OpenDBWithDriver("sqlite", ":memory:"+pragmas)
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() }) //nolint:errcheck // test cleanup
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
t.Fatalf("ping test database: %v", err)
|
||||
}
|
||||
|
||||
sub, err := fs.Sub(db.MigrationFS, "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrations sub fs: %v", err)
|
||||
}
|
||||
goose.SetBaseFS(sub)
|
||||
|
||||
if err := goose.Up(database, "."); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
return database, repository.New(database)
|
||||
}
|
||||
31
testutil/sessions.go
Normal file
31
testutil/sessions.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/sqlite3store"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
// NewTestSessionManager creates an SCS session manager backed by the
|
||||
// provided SQLite database. The background cleanup goroutine is stopped
|
||||
// automatically when the test finishes.
|
||||
func NewTestSessionManager(t *testing.T, db *sql.DB) *scs.SessionManager {
|
||||
t.Helper()
|
||||
|
||||
store := sqlite3store.New(db)
|
||||
t.Cleanup(func() { store.StopCleanup() })
|
||||
|
||||
sm := scs.New()
|
||||
sm.Store = store
|
||||
sm.Lifetime = 30 * 24 * time.Hour
|
||||
sm.Cookie.Path = "/"
|
||||
sm.Cookie.HttpOnly = true
|
||||
sm.Cookie.Secure = false
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
|
||||
return sm
|
||||
}
|
||||
130
ui/auth.go
130
ui/auth.go
@@ -1,130 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func LoginView(usernameBind, passwordBind, loginKeyDown, loginClick h.H, errorMsg string) h.H {
|
||||
var errorEl h.H
|
||||
if errorMsg != "" {
|
||||
errorEl = h.P(h.Class("alert alert-error mb-4"), h.Text(errorMsg))
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Login")),
|
||||
h.P(h.Class("mb-4"), h.Text("Sign in to your account")),
|
||||
errorEl,
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Username"), h.Attr("for", "username")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("username"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your username"),
|
||||
usernameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
),
|
||||
h.Label(h.Class("label"), h.Text("Password"), h.Attr("for", "password")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("password"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Enter your password"),
|
||||
passwordBind,
|
||||
h.Attr("required"),
|
||||
loginKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Login"),
|
||||
loginClick,
|
||||
),
|
||||
),
|
||||
h.P(
|
||||
h.Text("Don't have an account? "),
|
||||
h.A(h.Class("link"), h.Href("/register"), h.Text("Register")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func RegisterView(usernameBind, passwordBind, confirmBind, registerKeyDown, registerClick h.H, errorMsg string) h.H {
|
||||
var errorEl h.H
|
||||
if errorMsg != "" {
|
||||
errorEl = h.P(h.Class("alert alert-error mb-4"), h.Text(errorMsg))
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Register")),
|
||||
h.P(h.Class("mb-4"), h.Text("Create a new account")),
|
||||
errorEl,
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Username"), h.Attr("for", "username")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("username"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Choose a username"),
|
||||
usernameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
),
|
||||
h.Label(h.Class("label"), h.Text("Password"), h.Attr("for", "password")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("password"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Choose a password (min 8 chars)"),
|
||||
passwordBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
h.Label(h.Class("label"), h.Text("Confirm Password"), h.Attr("for", "confirm")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("confirm"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Confirm your password"),
|
||||
confirmBind,
|
||||
h.Attr("required"),
|
||||
registerKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Register"),
|
||||
registerClick,
|
||||
),
|
||||
),
|
||||
h.P(
|
||||
h.Text("Already have an account? "),
|
||||
h.A(h.Class("link"), h.Href("/login"), h.Text("Login")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func AuthHeader(username string, logoutClick h.H) h.H {
|
||||
return h.Div(h.Class("flex justify-center items-center gap-4 mb-4 p-2 bg-base-200 rounded-lg"),
|
||||
h.Span(h.Text("Logged in as "), h.Strong(h.Text(username))),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Class("btn btn-ghost btn-sm"),
|
||||
h.Text("Logout"),
|
||||
logoutClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func GuestBanner() h.H {
|
||||
return h.Div(h.Class("alert text-sm mb-4"),
|
||||
h.Text("Playing as guest. "),
|
||||
h.A(h.Class("link"), h.Href("/login"), h.Text("Login")),
|
||||
h.Text(" or "),
|
||||
h.A(h.Class("link"), h.Href("/register"), h.Text("Register")),
|
||||
h.Text(" to save your games."),
|
||||
)
|
||||
}
|
||||
69
ui/board.go
69
ui/board.go
@@ -1,69 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
// ColumnClickFn returns an h.H onClick attribute for a given column index
|
||||
type ColumnClickFn func(col int) h.H
|
||||
|
||||
func BoardComponent(g *game.Game, columnClick ColumnClickFn, myColor int) h.H {
|
||||
var cols []h.H
|
||||
|
||||
activeTurn := 0
|
||||
if g.Status == game.StatusInProgress {
|
||||
activeTurn = g.CurrentTurn
|
||||
}
|
||||
|
||||
for col := 0; col < 7; col++ {
|
||||
var cells []h.H
|
||||
for row := 0; row < 6; row++ {
|
||||
cellColor := g.Board[row][col]
|
||||
isWinning := g.IsWinningCell(row, col)
|
||||
isActiveTurn := cellColor != 0 && cellColor == activeTurn
|
||||
cells = append(cells, Cell(cellColor, isWinning, isActiveTurn))
|
||||
}
|
||||
|
||||
// Column is clickable only if it's player's turn and game is in progress
|
||||
canClick := g.Status == game.StatusInProgress && g.CurrentTurn == myColor
|
||||
cols = append(cols, Column(col, cells, columnClick, canClick))
|
||||
}
|
||||
|
||||
boardAttrs := []h.H{h.Class("board")}
|
||||
boardAttrs = append(boardAttrs, cols...)
|
||||
return h.Div(boardAttrs...)
|
||||
}
|
||||
|
||||
func Column(colIdx int, cells []h.H, columnClick ColumnClickFn, canClick bool) h.H {
|
||||
class := "column"
|
||||
if canClick {
|
||||
class += " clickable"
|
||||
}
|
||||
|
||||
attrs := []h.H{h.Class(class)}
|
||||
|
||||
if canClick && columnClick != nil {
|
||||
attrs = append(attrs, columnClick(colIdx))
|
||||
}
|
||||
|
||||
attrs = append(attrs, cells...)
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
|
||||
func Cell(color int, isWinning, isActiveTurn bool) h.H {
|
||||
class := "cell"
|
||||
switch color {
|
||||
case 1:
|
||||
class += " red"
|
||||
case 2:
|
||||
class += " yellow"
|
||||
}
|
||||
if isWinning {
|
||||
class += " winning"
|
||||
}
|
||||
if isActiveTurn {
|
||||
class += " active-turn"
|
||||
}
|
||||
return h.Div(h.Class(class))
|
||||
}
|
||||
64
ui/c4chat.go
64
ui/c4chat.go
@@ -1,64 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type C4ChatMessage struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Color int `json:"color"` // 1=Red, 2=Yellow
|
||||
Message string `json:"message"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
var c4ChatColors = map[int]string{
|
||||
1: "#4a2a3a",
|
||||
2: "#2a4545",
|
||||
}
|
||||
|
||||
func C4Chat(messages []C4ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H {
|
||||
var msgEls []h.H
|
||||
for _, m := range messages {
|
||||
color := "#666"
|
||||
if c, ok := c4ChatColors[m.Color]; ok {
|
||||
color = c
|
||||
}
|
||||
msgEls = append(msgEls, h.Div(h.Class("c4-chat-msg"),
|
||||
h.Span(
|
||||
h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)),
|
||||
h.Text(m.Nickname+": "),
|
||||
),
|
||||
h.Span(h.Text(m.Message)),
|
||||
))
|
||||
}
|
||||
|
||||
autoScroll := h.Script(h.Text(`
|
||||
(function(){
|
||||
var el = document.querySelector('.c4-chat-history');
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||
.observe(el, {childList:true, subtree:true});
|
||||
})();
|
||||
`))
|
||||
|
||||
historyAttrs := []h.H{h.Class("c4-chat-history")}
|
||||
historyAttrs = append(historyAttrs, msgEls...)
|
||||
historyAttrs = append(historyAttrs, autoScroll)
|
||||
|
||||
return h.Div(h.Class("c4-chat"),
|
||||
h.Div(historyAttrs...),
|
||||
h.Div(h.Class("c4-chat-input"), h.DataIgnoreMorph(),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Attr("placeholder", "Chat..."),
|
||||
h.Attr("autocomplete", "off"),
|
||||
msgBind,
|
||||
sendKeyDown,
|
||||
),
|
||||
h.Button(h.Type("button"), h.Text("Send"), sendClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
110
ui/gamelist.go
110
ui/gamelist.go
@@ -1,110 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type GameListItem struct {
|
||||
ID string
|
||||
Status int
|
||||
OpponentName string
|
||||
IsMyTurn bool
|
||||
LastPlayed time.Time
|
||||
}
|
||||
|
||||
func GameList(games []GameListItem, deleteClick func(id string) h.H) h.H {
|
||||
if len(games) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []h.H
|
||||
for _, g := range games {
|
||||
items = append(items, gameListEntry(g, deleteClick))
|
||||
}
|
||||
|
||||
listItems := []h.H{h.Class("flex flex-col gap-2")}
|
||||
listItems = append(listItems, items...)
|
||||
|
||||
return h.Div(h.Class("mt-8 text-left"),
|
||||
h.H3(h.Class("mb-4 text-center text-lg font-bold"), h.Text("Your Games")),
|
||||
h.Div(listItems...),
|
||||
)
|
||||
}
|
||||
|
||||
func gameListEntry(g GameListItem, deleteClick func(id string) h.H) h.H {
|
||||
statusText, statusClass := getStatusDisplay(g)
|
||||
|
||||
return h.Div(h.Class("flex items-center gap-2 p-2 bg-base-200 rounded-lg transition-colors hover:bg-base-300"),
|
||||
h.A(
|
||||
h.Href("/game/"+g.ID),
|
||||
h.Class("flex-1 flex justify-between items-center px-2 py-1 no-underline text-base-content"),
|
||||
h.Div(h.Class("flex flex-col gap-1"),
|
||||
h.Span(h.Class("font-bold"), h.Text(getOpponentDisplay(g))),
|
||||
h.Span(h.Class(statusClass), h.Text(statusText)),
|
||||
),
|
||||
h.Div(
|
||||
h.Span(h.Class("text-xs opacity-60"), h.Text(formatTimeAgo(g.LastPlayed))),
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Class("btn btn-ghost btn-sm btn-square hover:btn-error"),
|
||||
h.Text("\u00d7"),
|
||||
deleteClick(g.ID),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func getStatusDisplay(g GameListItem) (string, string) {
|
||||
switch game.GameStatus(g.Status) {
|
||||
case game.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent", "text-sm opacity-60"
|
||||
case game.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "Your turn!", "text-sm text-success font-bold"
|
||||
}
|
||||
return "Opponent's turn", "text-sm"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func getOpponentDisplay(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)
|
||||
}
|
||||
153
ui/lobby.go
153
ui/lobby.go
@@ -1,153 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type LobbyProps struct {
|
||||
NicknameBind h.H
|
||||
CreateGameKeyDown h.H
|
||||
CreateGameClick h.H
|
||||
IsLoggedIn bool
|
||||
Username string
|
||||
LogoutClick h.H
|
||||
UserGames []GameListItem
|
||||
DeleteGameClick func(id string) h.H
|
||||
ActiveTab string
|
||||
TabClickConnect4 h.H
|
||||
TabClickSnake h.H
|
||||
SnakeNicknameBind h.H
|
||||
SnakeSoloClicks []h.H
|
||||
SnakeMultiClicks []h.H
|
||||
ActiveSnakeGames []*snake.SnakeGame
|
||||
SelectedSpeedIndex int
|
||||
SpeedSelectClicks []h.H
|
||||
}
|
||||
|
||||
func BackToLobby() h.H {
|
||||
return h.A(h.Class("link text-sm opacity-70"), h.Href("/"), h.Text("← Back"))
|
||||
}
|
||||
|
||||
func StealthTitle(class string) h.H {
|
||||
return h.Span(h.Class(class),
|
||||
h.Span(h.Style("color:#4a2a3a"), h.Text("●")),
|
||||
h.Span(h.Style("color:#2a4545"), h.Text("●")),
|
||||
h.Span(h.Style("color:#4a2a3a"), h.Text("●")),
|
||||
h.Span(h.Style("color:#2a4545"), h.Text("●")),
|
||||
)
|
||||
}
|
||||
|
||||
func LobbyView(p LobbyProps) h.H {
|
||||
var authSection h.H
|
||||
if p.IsLoggedIn {
|
||||
authSection = AuthHeader(p.Username, p.LogoutClick)
|
||||
} else {
|
||||
authSection = GuestBanner()
|
||||
}
|
||||
|
||||
connect4Class := "tab"
|
||||
snakeClass := "tab"
|
||||
if p.ActiveTab == "snake" {
|
||||
snakeClass += " tab-active"
|
||||
} else {
|
||||
connect4Class += " tab-active"
|
||||
}
|
||||
|
||||
var tabContent h.H
|
||||
if p.ActiveTab == "snake" {
|
||||
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames, p.SelectedSpeedIndex, p.SpeedSelectClicks)
|
||||
} else {
|
||||
tabContent = connect4LobbyContent(p)
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
|
||||
authSection,
|
||||
h.H1(h.Class("text-3xl font-bold mb-4"), StealthTitle("")),
|
||||
h.Div(h.Class("tabs tabs-box mb-6 justify-center"),
|
||||
h.Button(h.Class(connect4Class), h.Type("button"), StealthTitle(""), p.TabClickConnect4),
|
||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("~~~~"), p.TabClickSnake),
|
||||
),
|
||||
tabContent,
|
||||
)
|
||||
}
|
||||
|
||||
func connect4LobbyContent(p LobbyProps) h.H {
|
||||
return h.Div(
|
||||
h.P(h.Class("mb-4"), h.Text("Start a new session")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
p.NicknameBind,
|
||||
h.Attr("required"),
|
||||
p.CreateGameKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Create Game"),
|
||||
p.CreateGameClick,
|
||||
),
|
||||
),
|
||||
GameList(p.UserGames, p.DeleteGameClick),
|
||||
)
|
||||
}
|
||||
|
||||
func NicknamePrompt(nicknameBind, setNicknameKeyDown, setNicknameClick h.H) h.H {
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Join Game")),
|
||||
h.P(h.Class("mb-4"), h.Text("Enter your nickname to join the game.")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
setNicknameKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Join"),
|
||||
setNicknameClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func GameJoinPrompt(loginClick, guestClick, registerClick h.H) h.H {
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Join Game")),
|
||||
h.P(h.Class("mb-4"), h.Text("Log in to track your game history, or continue as a guest.")),
|
||||
h.Div(h.Class("flex flex-col gap-2 my-4"),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Login"),
|
||||
loginClick,
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-secondary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Continue as Guest"),
|
||||
guestClick,
|
||||
),
|
||||
),
|
||||
h.P(h.Class("text-sm opacity-60"),
|
||||
h.Text("Don't have an account? "),
|
||||
h.A(h.Class("link"), h.Href("#"), h.Text("Register"), registerClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
112
ui/snakeboard.go
112
ui/snakeboard.go
@@ -1,112 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeBoard(sg *snake.SnakeGame) h.H {
|
||||
state := sg.State
|
||||
if state == nil || sg.Status != snake.StatusInProgress && sg.Status != snake.StatusFinished {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a lookup grid for rendering
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cell size scales with grid dimensions
|
||||
cellSize := cellSizeForGrid(state.Width, state.Height)
|
||||
|
||||
var rows []h.H
|
||||
for y := 0; y < state.Height; y++ {
|
||||
var cells []h.H
|
||||
for x := 0; x < state.Width; x++ {
|
||||
ci := grid[y][x]
|
||||
class := "snake-cell"
|
||||
style := fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize)
|
||||
|
||||
switch {
|
||||
case ci.snakeIdx == -2:
|
||||
class += " snake-food"
|
||||
case ci.snakeIdx >= 0:
|
||||
s := state.Snakes[ci.snakeIdx]
|
||||
colorIdx := ci.snakeIdx
|
||||
bg := ""
|
||||
if colorIdx < len(snake.SnakeColors) {
|
||||
bg = snake.SnakeColors[colorIdx]
|
||||
style += fmt.Sprintf("background:%s;", bg)
|
||||
}
|
||||
if !s.Alive {
|
||||
class += " snake-dead"
|
||||
}
|
||||
if ci.isHead {
|
||||
class += " snake-head"
|
||||
if bg != "" {
|
||||
style += fmt.Sprintf("box-shadow:0 0 8px %s;", bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells = append(cells, h.Div(h.Class(class), h.Attr("style", style)))
|
||||
}
|
||||
rowAttrs := append([]h.H{h.Class("snake-row")}, cells...)
|
||||
rows = append(rows, h.Div(rowAttrs...))
|
||||
}
|
||||
|
||||
boardStyle := fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", state.Width)
|
||||
attrs := []h.H{
|
||||
h.Class("snake-board"),
|
||||
h.Attr("style", boardStyle),
|
||||
}
|
||||
attrs = append(attrs, rows...)
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Slot int `json:"slot"`
|
||||
Message string `json:"message"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
func SnakeChat(messages []ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H {
|
||||
var msgEls []h.H
|
||||
for _, m := range messages {
|
||||
color := "#666"
|
||||
if m.Slot >= 0 && m.Slot < len(snake.SnakeColors) {
|
||||
color = snake.SnakeColors[m.Slot]
|
||||
}
|
||||
msgEls = append(msgEls, h.Div(h.Class("snake-chat-msg"),
|
||||
h.Span(
|
||||
h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)),
|
||||
h.Text(m.Nickname+": "),
|
||||
),
|
||||
h.Span(h.Text(m.Message)),
|
||||
))
|
||||
}
|
||||
|
||||
// Auto-scroll chat history to bottom on new messages
|
||||
autoScroll := h.Script(h.Text(`
|
||||
(function(){
|
||||
var el = document.querySelector('.snake-chat-history');
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||
.observe(el, {childList:true, subtree:true});
|
||||
})();
|
||||
`))
|
||||
|
||||
historyAttrs := []h.H{h.Class("snake-chat-history")}
|
||||
historyAttrs = append(historyAttrs, msgEls...)
|
||||
historyAttrs = append(historyAttrs, autoScroll)
|
||||
|
||||
return h.Div(h.Class("snake-chat"),
|
||||
h.Div(historyAttrs...),
|
||||
h.Div(h.Class("snake-chat-input"),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Attr("placeholder", "Chat..."),
|
||||
h.Attr("autocomplete", "off"),
|
||||
// Prevent key events from bubbling to the game's window-level handler
|
||||
h.Attr("onkeydown", "event.stopPropagation()"),
|
||||
msgBind,
|
||||
sendKeyDown,
|
||||
),
|
||||
h.Button(h.Type("button"), h.Text("Send"), sendClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
124
ui/snakelobby.go
124
ui/snakelobby.go
@@ -1,124 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame, selectedSpeedIndex int, speedSelectClicks []h.H) h.H {
|
||||
// Solo play buttons
|
||||
var soloButtons []h.H
|
||||
for i, preset := range snake.GridPresets {
|
||||
var click h.H
|
||||
if i < len(soloClicks) {
|
||||
click = soloClicks[i]
|
||||
}
|
||||
soloButtons = append(soloButtons,
|
||||
h.Button(
|
||||
h.Class("btn btn-secondary"),
|
||||
h.Type("button"),
|
||||
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||
click,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Multiplayer buttons
|
||||
var multiButtons []h.H
|
||||
for i, preset := range snake.GridPresets {
|
||||
var click h.H
|
||||
if i < len(multiClicks) {
|
||||
click = multiClicks[i]
|
||||
}
|
||||
multiButtons = append(multiButtons,
|
||||
h.Button(
|
||||
h.Class("btn btn-primary"),
|
||||
h.Type("button"),
|
||||
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||
click,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
nicknameField := h.Div(h.Class("mb-4"),
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("snake-nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Speed selector
|
||||
var speedButtons []h.H
|
||||
for i, preset := range snake.SpeedPresets {
|
||||
btnClass := "btn btn-sm"
|
||||
if i == selectedSpeedIndex {
|
||||
btnClass += " btn-active"
|
||||
}
|
||||
var click h.H
|
||||
if i < len(speedSelectClicks) {
|
||||
click = speedSelectClicks[i]
|
||||
}
|
||||
speedButtons = append(speedButtons, h.Button(
|
||||
h.Class(btnClass),
|
||||
h.Type("button"),
|
||||
h.Text(preset.Name),
|
||||
click,
|
||||
))
|
||||
}
|
||||
speedSelector := h.Div(h.Class("mb-4"),
|
||||
h.Label(h.Class("label"), h.Text("Speed")),
|
||||
h.Div(append([]h.H{h.Class("btn-group")}, speedButtons...)...),
|
||||
)
|
||||
|
||||
soloSection := h.Div(h.Class("mb-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Play Solo")),
|
||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, soloButtons...)...),
|
||||
)
|
||||
|
||||
multiSection := h.Div(h.Class("mb-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Multiplayer Game")),
|
||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, multiButtons...)...),
|
||||
)
|
||||
|
||||
var gameListEl h.H
|
||||
if len(activeGames) > 0 {
|
||||
var items []h.H
|
||||
for _, g := range activeGames {
|
||||
playerCount := g.PlayerCount()
|
||||
sizeLabel := fmt.Sprintf("%d×%d", g.State.Width, g.State.Height)
|
||||
statusLabel := "Waiting"
|
||||
if g.Status == snake.StatusCountdown {
|
||||
statusLabel = "Starting soon"
|
||||
}
|
||||
items = append(items, h.A(
|
||||
h.Href("/snake/"+g.ID),
|
||||
h.Class("flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content"),
|
||||
h.Span(h.Text(fmt.Sprintf("%s — %d/8 players", sizeLabel, playerCount))),
|
||||
h.Span(h.Class("text-sm opacity-60"), h.Text(statusLabel)),
|
||||
))
|
||||
}
|
||||
listAttrs := []h.H{h.Class("flex flex-col gap-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
gameListEl = h.Div(h.Class("mt-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2 text-center"), h.Text("Join a Game")),
|
||||
h.Div(listAttrs...),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
nicknameField,
|
||||
speedSelector,
|
||||
soloSection,
|
||||
multiSection,
|
||||
gameListEl,
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||
h.Text("Ready?"),
|
||||
)
|
||||
}
|
||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||
h.Text("Waiting for players..."),
|
||||
)
|
||||
|
||||
case snake.StatusCountdown:
|
||||
remaining := time.Until(sg.CountdownEnd)
|
||||
secs := int(math.Ceil(remaining.Seconds()))
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
return h.Div(h.Class("alert alert-info text-xl font-bold"),
|
||||
h.Text(fmt.Sprintf("Starting in %d...", secs)),
|
||||
)
|
||||
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[mySlot]
|
||||
if s != nil && !s.Alive {
|
||||
return h.Div(h.Class("alert alert-error text-xl font-bold"),
|
||||
h.Text("You're out!"),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Show score during single player gameplay
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||
h.Text(fmt.Sprintf("Score: %d", sg.Score)),
|
||||
)
|
||||
}
|
||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||
h.Text("Go!"),
|
||||
)
|
||||
|
||||
case snake.StatusFinished:
|
||||
var msg string
|
||||
var class string
|
||||
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
msg = fmt.Sprintf("Game Over! Score: %d", sg.Score)
|
||||
class = "alert alert-info text-xl font-bold"
|
||||
} else if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
msg = "You win!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else {
|
||||
msg = sg.Winner.Nickname + " wins!"
|
||||
class = "alert alert-error text-xl font-bold"
|
||||
}
|
||||
} else {
|
||||
msg = "It's a draw!"
|
||||
class = "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
|
||||
content := []h.H{h.Class(class), h.Text(msg)}
|
||||
|
||||
if sg.RematchGameID != nil {
|
||||
content = append(content,
|
||||
h.A(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Href("/snake/"+*sg.RematchGameID),
|
||||
h.Text("Join Rematch"),
|
||||
),
|
||||
)
|
||||
} else if rematchClick != nil {
|
||||
content = append(content,
|
||||
h.Button(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Type("button"),
|
||||
h.Text("Play again"),
|
||||
rematchClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(content...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
|
||||
var items []h.H
|
||||
|
||||
for i, p := range sg.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
colorHex := "#666"
|
||||
if i < len(snake.SnakeColors) {
|
||||
colorHex = snake.SnakeColors[i]
|
||||
}
|
||||
|
||||
name := p.Nickname
|
||||
if i == mySlot {
|
||||
name += " (You)"
|
||||
}
|
||||
|
||||
var statusEl h.H
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[i]
|
||||
if s != nil {
|
||||
if s.Alive {
|
||||
length := len(s.Body)
|
||||
statusEl = h.Span(h.Class("text-sm opacity-60"), h.Text(fmt.Sprintf(" (%d)", length)))
|
||||
} else {
|
||||
statusEl = h.Span(h.Class("text-sm opacity-40"), h.Text(" (dead)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chipStyle := fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", colorHex)
|
||||
|
||||
items = append(items, h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Attr("style", chipStyle)),
|
||||
h.Span(h.Text(name)),
|
||||
statusEl,
|
||||
))
|
||||
}
|
||||
|
||||
listAttrs := []h.H{h.Class("flex flex-wrap gap-4 mb-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
return h.Div(listAttrs...)
|
||||
}
|
||||
|
||||
func SnakeInviteLink(gameID string) h.H {
|
||||
fullURL := getBaseURL() + "/snake/" + gameID
|
||||
return h.Div(h.Class("mt-4 text-center"),
|
||||
h.P(h.Text("Share this link to invite players:")),
|
||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||
h.Text(fullURL),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-sm mt-2"),
|
||||
h.Type("button"),
|
||||
h.Text("Copy Link"),
|
||||
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||
),
|
||||
)
|
||||
}
|
||||
141
ui/status.go
141
ui/status.go
@@ -1,141 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
|
||||
var message string
|
||||
var class string
|
||||
|
||||
switch g.Status {
|
||||
case game.StatusWaitingForPlayer:
|
||||
message = "Waiting for opponent..."
|
||||
class = "alert bg-base-200 text-xl font-bold"
|
||||
case game.StatusInProgress:
|
||||
if g.CurrentTurn == myColor {
|
||||
message = "Your turn!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else {
|
||||
opponentName := getOpponentName(g, myColor)
|
||||
message = opponentName + "'s turn"
|
||||
class = "alert bg-base-200 text-xl font-bold"
|
||||
}
|
||||
case game.StatusWon:
|
||||
if g.Winner != nil && g.Winner.Color == myColor {
|
||||
message = "You win!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else if g.Winner != nil {
|
||||
message = g.Winner.Nickname + " wins!"
|
||||
class = "alert alert-error text-xl font-bold"
|
||||
}
|
||||
case game.StatusDraw:
|
||||
message = "It's a draw!"
|
||||
class = "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
|
||||
content := []h.H{
|
||||
h.Class(class),
|
||||
h.Text(message),
|
||||
}
|
||||
|
||||
// Show rematch options for finished games
|
||||
if g.IsFinished() {
|
||||
if g.RematchGameID != nil {
|
||||
content = append(content,
|
||||
h.A(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Href("/game/"+*g.RematchGameID),
|
||||
h.Text("Join Rematch"),
|
||||
),
|
||||
)
|
||||
} else if playAgainClick != nil {
|
||||
content = append(content,
|
||||
h.Button(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Type("button"),
|
||||
h.Text("Play again"),
|
||||
playAgainClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return h.Div(content...)
|
||||
}
|
||||
|
||||
func getOpponentName(g *game.Game, myColor int) string {
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.Color != myColor {
|
||||
return p.Nickname
|
||||
}
|
||||
}
|
||||
return "Opponent"
|
||||
}
|
||||
|
||||
func PlayerInfo(g *game.Game, myColor int) h.H {
|
||||
var myName, opponentName string
|
||||
var myColorClass, opponentColorClass string
|
||||
|
||||
for _, p := range g.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if p.Color == myColor {
|
||||
myName = p.Nickname
|
||||
if p.Color == 1 {
|
||||
myColorClass = "red"
|
||||
} else {
|
||||
myColorClass = "yellow"
|
||||
}
|
||||
} else {
|
||||
opponentName = p.Nickname
|
||||
if p.Color == 1 {
|
||||
opponentColorClass = "red"
|
||||
} else {
|
||||
opponentColorClass = "yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opponentName == "" {
|
||||
opponentName = "Waiting..."
|
||||
}
|
||||
|
||||
return h.Div(h.Class("flex gap-8 mb-2"),
|
||||
h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Class("player-chip "+myColorClass)),
|
||||
h.Span(h.Text(myName+" (You)")),
|
||||
),
|
||||
h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Class("player-chip "+opponentColorClass)),
|
||||
h.Span(h.Text(opponentName)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func getBaseURL() string {
|
||||
if url := os.Getenv("APP_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
return "https://games.adriatica.io"
|
||||
}
|
||||
|
||||
func InviteLink(gameID string) h.H {
|
||||
fullURL := getBaseURL() + "/game/" + gameID
|
||||
return h.Div(h.Class("mt-4 text-center"),
|
||||
h.P(h.Text("Share this link with your opponent:")),
|
||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||
h.Text(fullURL),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-sm mt-2"),
|
||||
h.Type("button"),
|
||||
h.Text("Copy Link"),
|
||||
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||
),
|
||||
)
|
||||
}
|
||||
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