51 Commits

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

Both c4game and snakegame now use chat.Room for message management and
chatcomponents.Chat for rendering, eliminating the duplicated
ChatMessage types, chat templ components, chatAutoScroll scripts,
color functions, and inline buffer management.
2026-03-02 19:20:21 -10:00
Ryan Hamamura
7eadfbbb0c refactor: extract session helpers for player identity resolution
Add GetPlayerID, GetUserID, GetNickname to the sessions package.
Remove the inline player-ID-from-session pattern duplicated across
every handler in c4game and snakegame, and the local getPlayerID
helper in snakegame.
2026-03-02 19:16:09 -10:00
Ryan Hamamura
063b03ce25 refactor: extract shared player.ID type and GenerateID to player package
Both game and snake packages had identical PlayerID types and the snake
package imported game.GenerateID. Now both use player.ID and
player.GenerateID from the shared player package.
2026-03-02 19:09:01 -10:00
f47eb4cdf3 Merge pull request 'refactor: deduplicate persistence, add upsert queries, throttle snake saves' (#4) from refactor/game-efficiency into main
Some checks failed
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Failing after 17s
2026-03-03 05:02:04 +00:00
Ryan Hamamura
9a20467438 refactor: add save()/savePlayer() methods on game instances
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Wrap free persistence functions in instance methods for cleaner call
sites (gi.save() instead of saveGame(gi.queries, gi.game)). Methods
log errors via zerolog before returning them.
2026-03-02 18:51:18 -10:00
Ryan Hamamura
cb5458c9fc ci: generate templ files before test and lint steps
All checks were successful
CI / Deploy / test (pull_request) Successful in 16s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
2026-03-02 18:39:33 -10:00
Ryan Hamamura
bc6488f063 refactor: deduplicate persistence, add upsert queries, throttle snake saves
Some checks failed
CI / Deploy / test (pull_request) Failing after 15s
CI / Deploy / lint (pull_request) Failing after 23s
CI / Deploy / deploy (pull_request) Has been skipped
- Replace Create+Get+Update with UpsertGame/UpsertSnakeGame queries
- Extract free functions (saveGame, loadGame, etc.) from duplicated
  receiver methods on Store and Instance types
- Remove duplicate generateID from snake package, reuse game.GenerateID
- Throttle snake game DB writes to every 2s instead of every tick
- Fix double-lock in c4game chat handler
- Update all code for sqlc pointer types (*string instead of sql.NullString)
2026-03-02 16:56:29 -10:00
9c3f659e96 Merge pull request 'fix: add Enter key handlers to all auth and nickname inputs' (#3) from fix/enter-key-handlers into main
Some checks failed
CI / Deploy / test (push) Failing after 12s
CI / Deploy / lint (push) Failing after 24s
CI / Deploy / deploy (push) Has been skipped
2026-03-03 01:34:21 +00:00
Ryan Hamamura
2bea5bb489 chore: gitignore generated _templ.go files, track .templ sources
Some checks failed
CI / Deploy / test (pull_request) Failing after 13s
CI / Deploy / lint (pull_request) Failing after 24s
CI / Deploy / deploy (pull_request) Has been skipped
Generated _templ.go files are deterministic output from .templ sources,
same as output.css from input.css. Remove them from version control to
reduce diff noise and merge conflicts. Add build:templ and live:templ
tasks to the Taskfile so generation happens as part of the build.
2026-03-02 15:27:38 -10:00
Ryan Hamamura
4f1ee11fa3 fix: add Enter key handlers to all auth and nickname inputs
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Pressing Enter on the username field in login/register or the nickname
field in the join-game prompt now submits the form, matching user
expectations. Also add *.templ to the gitignore allowlist.
2026-03-02 15:06:01 -10:00
Ryan Hamamura
8c6e5d24ac fix: add goose migration for sessions table
All checks were successful
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 28s
The sqlite3store library expects the sessions table to exist but does
not create it. On fresh deployments, the session store would fail.
Uses IF NOT EXISTS to be safe on databases where the table was created
outside of goose.
2026-03-02 15:00:07 -10:00
021215ed94 Merge pull request 'refactor: replace via framework with chi + templ + datastar' (#2) from refactor/remove-via into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 24s
CI / Deploy / deploy (push) Successful in 1m58s
2026-03-03 00:47:20 +00:00
Ryan Hamamura
303c45cab1 feat: add downloader binary for client-side dependencies
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Replace the curl-based Taskfile download task with a Go binary that
concurrently fetches datastar.js, datastar.js.map, daisyui.mjs, and
daisyui-theme.mjs. Update vendored libs to latest versions.
2026-03-02 14:34:53 -10:00
Ryan Hamamura
587f392b8b fix: serve datastar locally and clean up session/route config
Replace CDN-hosted datastar beta.11 with local v1.0.0-RC.7 to fix
client-side expression incompatibilities with the Go SDK. Also fix
quoted CSS class keys in data-class expressions, harden session cookie
settings (named cookie, Secure flag), simplify SetupRoutes to not
return an error, and regenerate templ output.
2026-03-02 14:34:39 -10:00
Ryan Hamamura
5120eef776 refactor: streamline routes to RESTful naming conventions
All checks were successful
CI / Deploy / test (pull_request) Successful in 13s
CI / Deploy / lint (pull_request) Successful in 23s
CI / Deploy / deploy (pull_request) Has been skipped
Remove /api/ prefix and consolidate route groups:
- /api/lobby/* -> /games, /snake, /logout (top-level)
- /game/{game_id} + /api/game/{game_id}/* -> /games/{id}/*
- /snake/{game_id} + /api/snake/{game_id}/* -> /snake/{id}/*
- /api/auth/* -> /auth/*
- Standardize snake join page to use return_url= (was return=)
2026-03-02 13:19:03 -10:00
Ryan Hamamura
fcc6b70e84 fix: warn when .env file is missing instead of silently ignoring
All checks were successful
CI / Deploy / test (pull_request) Successful in 13s
CI / Deploy / lint (pull_request) Successful in 24s
CI / Deploy / deploy (pull_request) Has been skipped
2026-03-02 12:42:10 -10:00
Ryan Hamamura
67d4dba37f fix: suppress gosec G117 on auth form signal structs
All checks were successful
CI / Deploy / test (pull_request) Successful in 7s
CI / Deploy / lint (pull_request) Successful in 47s
CI / Deploy / deploy (pull_request) Has been skipped
2026-03-02 12:40:02 -10:00
Ryan Hamamura
afd8a3e9d0 fix: resolve all linting errors and add SSE compression
Some checks failed
CI / Deploy / test (pull_request) Successful in 8s
CI / Deploy / lint (pull_request) Failing after 44s
CI / Deploy / deploy (pull_request) Has been skipped
- Add brotli compression (level 5) to long-lived SSE event streams
  (HandleGameEvents, HandleSnakeEvents) to reduce wire payload
- Fix all errcheck violations with nolint annotations for best-effort calls
- Fix goimports: separate stdlib, third-party, and local import groups
- Fix staticcheck: add package comments, use tagged switch
- Zero lint issues remaining
2026-03-02 12:38:21 -10:00
Ryan Hamamura
2aa026b1d5 refactor: remove persister abstraction layer
Some checks failed
CI / Deploy / test (pull_request) Successful in 8s
CI / Deploy / lint (pull_request) Failing after 46s
CI / Deploy / deploy (pull_request) Has been skipped
Inline persistence logic directly into game stores and handlers:
- game/persist.go: DB mapping methods on GameStore and GameInstance
- snake/persist.go: DB mapping methods on SnakeStore and SnakeGameInstance
- Chat persistence inlined into c4game handlers
- Delete db/persister.go (GamePersister, SnakePersister, ChatPersister)
- Stores now take *repository.Queries directly instead of Persister interface
2026-03-02 12:30:33 -10:00
Ryan Hamamura
8c3b3fc6ea refactor: replace via framework with chi + templ + datastar
Some checks failed
CI / Deploy / test (pull_request) Successful in 28s
CI / Deploy / lint (pull_request) Failing after 42s
CI / Deploy / deploy (pull_request) Has been skipped
Migrate from the via meta-framework to direct dependencies:
- chi for routing, templ for HTML templates, datastar for SSE/reactivity
- Feature-sliced architecture (features/{auth,lobby,c4game,snakegame}/)
- Shared layouts and components (features/common/)
- Handler factory pattern (HandleX(deps) http.HandlerFunc)
- Embedded NATS server (nats/), SCS sessions (sessions/), chi router wiring (router/)
- Move ChatMessage domain type from ui package to game package
- Remove old ui/ package (gomponents-based via/h views)
- Remove via dependency from go.mod entirely
2026-03-02 12:16:25 -10:00
Ryan Hamamura
2df20c2840 refactor: adopt portigo infrastructure patterns
Add config package with build-tag-switched dev/prod environments,
structured logging via zerolog, Taskfile for dev workflow, golangci-lint
config, testutil package, and improved DB setup with proper SQLite
pragmas and cleanup. Rename sqlc output package from gen to repository.

Switch to allowlist .gitignore, Alpine+UPX+scratch Dockerfile, and
CI pipeline with test/lint gates before deploy.
2026-03-02 11:48:47 -10:00
Ryan Hamamura
6d4f3eb821 fix: add explicit --login and --repo flags to tea commands
All checks were successful
Deploy c4 / deploy (push) Successful in 49s
2026-02-20 17:05:46 -10:00
Ryan Hamamura
e68e4b48f5 fix: resolve nil pubsub preventing live game updates
All checks were successful
Deploy c4 / deploy (push) Successful in 45s
v.PubSub() was captured at startup before v.Start() initialized NATS,
so both stores held nil and notify() silently no-oped. Replace the
PubSub interface with a callback that evaluates v.PubSub() lazily at
call time.
2026-02-20 12:37:28 -10:00
Ryan Hamamura
91b5f2b80c deps: update ryanhamamura/via to v0.23.0
All checks were successful
Deploy c4 / deploy (push) Successful in 57s
Remove ContextSuspendAfter and ContextTTL options, which were
deleted upstream. Contexts now persist until SSE close beacon
or server shutdown.
2026-02-20 12:06:51 -10:00
Ryan Hamamura
73b6e3bcc5 deps: update ryanhamamura/via to v0.21.2
All checks were successful
Deploy c4 / deploy (push) Successful in 1m0s
2026-02-20 09:26:24 -10:00
93 changed files with 5392 additions and 5105 deletions

View File

@@ -1,10 +1,10 @@
c4 games
c4.db games.db
data/ data/
deploy/ deploy/
.env .env
.git .git
.gitignore .gitignore
assets/css/output.css assets/css/output.css
c4-deploy-*.tar.gz games-deploy-*.tar.gz
c4-deploy-*_b64*.txt games-deploy-*_b64*.txt

View File

@@ -1,5 +1,16 @@
# 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 # APP_URL=http://localhost:7331
# Server port (defaults to 7331) # Server port. Defaults to 7331.
# PORT=7331 # PORT=7331
# Goose CLI migration config (only needed for running goose manually)
GOOSE_DRIVER=sqlite3
GOOSE_DBSTRING=data/games.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)
GOOSE_MIGRATION_DIR=db/migrations

View File

@@ -1,17 +1,55 @@
name: Deploy c4 name: CI / Deploy
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
env: env:
DEPLOY_DIR: /home/ryan/c4 DEPLOY_DIR: /home/ryan/games
jobs: 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: deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [test, lint]
runs-on: games runs-on: games
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for git describe
- name: Sync to deploy directory - name: Sync to deploy directory
run: | run: |
@@ -21,8 +59,10 @@ jobs:
- name: Ensure data directory exists with correct ownership - name: Ensure data directory exists with correct ownership
run: | run: |
mkdir -p $DEPLOY_DIR/data 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 - name: Rebuild and restart
run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans run: |
cd $DEPLOY_DIR
VERSION=$(git describe --tags --always)
COMMIT=$(git rev-parse --short HEAD)
VERSION=$VERSION COMMIT=$COMMIT docker compose up -d --build --remove-orphans

42
.gitignore vendored
View File

@@ -1,8 +1,36 @@
c4 # Allowlisting gitignore: ignore everything, then un-ignore what we track.
c4.db # source: https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
data/
.env
# Deploy artifacts # Ignore everything
c4-deploy-*.tar.gz *
c4-deploy-*_b64*.txt
# But not these files...
!.gitignore
!*.go
!*.templ
!*.sql
!go.sum
!go.mod
!Taskfile.yml
!sqlc.yaml
!.golangci.yml
!.gitea/workflows/*.yml
!.env.example
!LICENSE
!assets/**/*
# Generated files stay out of version control
*_templ.go
assets/css/output.css
# Deploy scripts and configs
!deploy/*.sh
!deploy/*.service
!docker-compose.yml
!Dockerfile
# ...even if they are in subdirectories
!*/

45
.golangci.yml Normal file
View 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

View File

@@ -1,29 +1,23 @@
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 WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN go tool templ generate
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
RUN 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 FROM scratch
ENV PORT=8080
RUN apt-get update && \ COPY --from=build /bin/games /
apt-get install -y --no-install-recommends ca-certificates wget && \ ENTRYPOINT ["/games"]
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"]

85
Taskfile.yml Normal file
View File

@@ -0,0 +1,85 @@
version: "3"
tasks:
download:
desc: Download latest client-side libs
cmds:
- go run cmd/downloader/main.go
build:templ:
desc: Compile .templ files to Go
cmds:
- go tool templ generate
sources:
- "**/*.templ"
generates:
- "**/*_templ.go"
build:styles:
desc: Build TailwindCSS styles
cmds:
- go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
sources:
- "assets/css/input.css"
- "**/*.templ"
- "**/*.go"
generates:
- "assets/css/output.css"
build:
desc: Production build to bin/games
cmds:
- go build -o bin/games .
deps:
- build:templ
- build:styles
live:templ:
desc: Watch and recompile .templ files
cmds:
- go tool templ generate -watch
live:styles:
desc: Watch and rebuild TailwindCSS styles
cmds:
- 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:
- 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

9
assets/js/datastar.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,4 @@
// Package auth provides password hashing and verification using bcrypt.
package auth package auth
import ( import (

149
chat/chat.go Normal file
View File

@@ -0,0 +1,149 @@
// 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.
// Returns the new message and a snapshot of all messages.
func (r *Room) Receive(data []byte) (Message, []Message) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return msg, nil
}
r.mu.Lock()
r.messages = append(r.messages, msg)
if len(r.messages) > maxMessages {
r.messages = r.messages[len(r.messages)-maxMessages:]
}
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
r.mu.Unlock()
return msg, snapshot
}
// 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 creates a NATS channel subscription for the room's subject.
// Caller is responsible for unsubscribing.
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
ch := make(chan *nats.Msg, 64)
sub, err := r.nc.ChanSubscribe(r.subject, ch)
if err != nil {
return nil, nil, err
}
return ch, sub, nil
}
func (r *Room) saveMessage(msg Message) {
err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: r.roomID,
Nickname: msg.Nickname,
Color: int64(msg.Slot),
Message: msg.Message,
CreatedAt: msg.Time,
})
if err != nil {
log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message")
}
}
func (r *Room) loadMessages() []Message {
rows, err := r.queries.GetChatMessages(context.Background(), r.roomID)
if err != nil {
return nil
}
msgs := make([]Message, len(rows))
for i, row := range rows {
msgs[i] = Message{
Nickname: row.Nickname,
Slot: int(row.Color),
Message: row.Message,
Time: row.CreatedAt,
}
}
// DB returns newest-first; reverse for chronological display
slices.Reverse(msgs)
return msgs
}

View File

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

100
cmd/downloader/main.go Normal file
View File

@@ -0,0 +1,100 @@
package main
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sync"
)
// Asset directories, relative to project root.
const (
jsDir = "assets/js"
cssDir = "assets/css"
)
// files maps download URLs to local destination paths.
var files = map[string]string{
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js": jsDir + "/datastar.js",
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js.map": jsDir + "/datastar.js.map",
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.mjs": cssDir + "/daisyui.mjs",
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.mjs": cssDir + "/daisyui-theme.mjs",
}
func main() {
if err := run(); err != nil {
slog.Error("failure", "error", err)
os.Exit(1)
}
}
func run() error {
dirs := []string{jsDir, cssDir}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
}
return download(files)
}
func download(files map[string]string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(files))
for url, dest := range files {
wg.Go(func() {
base := filepath.Base(dest)
slog.Info("downloading...", "file", base, "url", url)
if err := downloadFile(url, dest); err != nil {
errCh <- fmt.Errorf("download %s: %w", base, err)
} else {
slog.Info("finished", "file", base)
}
})
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url) //nolint:gosec,noctx // static URLs, simple tool
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: status %s", url, resp.Status)
}
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
if err != nil {
return fmt.Errorf("create %s: %w", dest, err)
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() //nolint:errcheck
return fmt.Errorf("write %s: %w", dest, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close %s: %w", dest, err)
}
return nil
}

76
config/config.go Normal file
View 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
View 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
View File

@@ -0,0 +1,9 @@
//go:build !dev
package config
func Load() *Config {
cfg := loadBase()
cfg.Environment = Prod
return cfg
}

View 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:",
}
}

View File

@@ -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. // DropPiece attempts to drop a piece in the given column.
// Returns (row placed, success). // Returns (row placed, success).

126
connect4/persist.go Normal file
View 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
View 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
}

View File

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

View File

@@ -1,33 +1,70 @@
// Package db handles SQLite database setup, pragma configuration, and
// goose migrations.
package db package db
import ( import (
"database/sql" "database/sql"
"embed" "embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrations embed.FS var MigrationFS embed.FS
var DB *sql.DB 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 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 { 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 { 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 { if err := goose.Up(DB, "."); err != nil {
return err 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
} }

View File

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

View 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;

View File

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

View File

@@ -1,16 +1,18 @@
-- name: CreateGame :one -- name: UpsertGame :exec
INSERT INTO games (id, board, current_turn, status) INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
RETURNING *; 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 -- name: GetGame :one
SELECT * FROM games WHERE id = ?; 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 -- name: DeleteGame :exec
DELETE FROM games WHERE id = ?; DELETE FROM games WHERE id = ?;

View File

@@ -1,16 +1,17 @@
-- name: CreateSnakeGame :one -- name: UpsertSnakeGame :exec
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed) 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, ?, ?) VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
RETURNING *; 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 -- name: GetSnakeGame :one
SELECT * FROM games WHERE id = ? AND game_type = 'snake'; 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 -- name: DeleteSnakeGame :exec
DELETE FROM games WHERE id = ? AND game_type = 'snake'; DELETE FROM games WHERE id = ? AND game_type = 'snake';

View File

@@ -3,7 +3,7 @@
// sqlc v1.30.0 // sqlc v1.30.0
// source: chat.sql // source: chat.sql
package gen package repository
import ( import (
"context" "context"
@@ -15,11 +15,11 @@ VALUES (?, ?, ?, ?, ?)
` `
type CreateChatMessageParams struct { type CreateChatMessageParams struct {
GameID string GameID string `db:"game_id" json:"game_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Message string Message string `db:"message" json:"message"`
CreatedAt int64 CreatedAt int64 `db:"created_at" json:"created_at"`
} }
func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error { func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error {
@@ -40,13 +40,13 @@ ORDER BY created_at DESC, id DESC
LIMIT 50 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) rows, err := q.db.QueryContext(ctx, getChatMessages, gameID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []ChatMessage var items []*ChatMessage
for rows.Next() { for rows.Next() {
var i ChatMessage var i ChatMessage
if err := rows.Scan( if err := rows.Scan(
@@ -59,7 +59,7 @@ func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMes
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err

View File

@@ -2,7 +2,7 @@
// versions: // versions:
// sqlc v1.30.0 // sqlc v1.30.0
package gen package repository
import ( import (
"context" "context"

View File

@@ -3,67 +3,25 @@
// sqlc v1.30.0 // sqlc v1.30.0
// source: games.sql // source: games.sql
package gen package repository
import ( import (
"context" "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 const createGamePlayer = `-- name: CreateGamePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
` `
type CreateGamePlayerParams struct { type CreateGamePlayerParams struct {
GameID string GameID string `db:"game_id" json:"game_id"`
UserID sql.NullString UserID *string `db:"user_id" json:"user_id"`
GuestPlayerID sql.NullString GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Slot int64 Slot int64 `db:"slot" json:"slot"`
} }
func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error { 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 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) rows, err := q.db.QueryContext(ctx, getActiveGames)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Game var items []*Game
for rows.Next() { for rows.Next() {
var i Game var i Game
if err := rows.Scan( if err := rows.Scan(
@@ -120,7 +78,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err 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 = ? 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) row := q.db.QueryRowContext(ctx, getGame, id)
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -156,20 +114,20 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
&i.Score, &i.Score,
&i.SnakeSpeed, &i.SnakeSpeed,
) )
return i, err return &i, err
} }
const getGamePlayers = `-- name: GetGamePlayers :many const getGamePlayers = `-- name: GetGamePlayers :many
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? 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) rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GamePlayer var items []*GamePlayer
for rows.Next() { for rows.Next() {
var i GamePlayer var i GamePlayer
if err := rows.Scan( if err := rows.Scan(
@@ -183,7 +141,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -201,13 +159,13 @@ WHERE gp.user_id = ?
ORDER BY g.updated_at DESC 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) rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Game var items []*Game
for rows.Next() { for rows.Next() {
var i Game var i Game
if err := rows.Scan( if err := rows.Scan(
@@ -230,7 +188,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -257,21 +215,21 @@ ORDER BY g.updated_at DESC
` `
type GetUserActiveGamesRow struct { type GetUserActiveGamesRow struct {
ID string ID string `db:"id" json:"id"`
Status int64 Status int64 `db:"status" json:"status"`
CurrentTurn int64 CurrentTurn int64 `db:"current_turn" json:"current_turn"`
UpdatedAt sql.NullTime UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
MyColor int64 MyColor int64 `db:"my_color" json:"my_color"`
OpponentNickname sql.NullString 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) rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetUserActiveGamesRow var items []*GetUserActiveGamesRow
for rows.Next() { for rows.Next() {
var i GetUserActiveGamesRow var i GetUserActiveGamesRow
if err := rows.Scan( if err := rows.Scan(
@@ -284,7 +242,7 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -295,31 +253,38 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
return items, nil return items, nil
} }
const updateGame = `-- name: UpdateGame :exec const upsertGame = `-- name: UpsertGame :exec
UPDATE games INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
WHERE id = ? 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 { type UpsertGameParams struct {
Board string ID string `db:"id" json:"id"`
CurrentTurn int64 Board string `db:"board" json:"board"`
Status int64 CurrentTurn int64 `db:"current_turn" json:"current_turn"`
WinnerUserID sql.NullString Status int64 `db:"status" json:"status"`
WinningCells sql.NullString WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
RematchGameID sql.NullString WinningCells *string `db:"winning_cells" json:"winning_cells"`
ID string RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
} }
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error { func (q *Queries) UpsertGame(ctx context.Context, arg UpsertGameParams) error {
_, err := q.db.ExecContext(ctx, updateGame, _, err := q.db.ExecContext(ctx, upsertGame,
arg.ID,
arg.Board, arg.Board,
arg.CurrentTurn, arg.CurrentTurn,
arg.Status, arg.Status,
arg.WinnerUserID, arg.WinnerUserID,
arg.WinningCells, arg.WinningCells,
arg.RematchGameID, arg.RematchGameID,
arg.ID,
) )
return err return err
} }

60
db/repository/models.go Normal file
View 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"`
}

View File

@@ -3,73 +3,25 @@
// sqlc v1.30.0 // sqlc v1.30.0
// source: snake_games.sql // source: snake_games.sql
package gen package repository
import ( import (
"context" "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 const createSnakePlayer = `-- name: CreateSnakePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
` `
type CreateSnakePlayerParams struct { type CreateSnakePlayerParams struct {
GameID string GameID string `db:"game_id" json:"game_id"`
UserID sql.NullString UserID *string `db:"user_id" json:"user_id"`
GuestPlayerID sql.NullString GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Slot int64 Slot int64 `db:"slot" json:"slot"`
} }
func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error { 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 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) rows, err := q.db.QueryContext(ctx, getActiveSnakeGames)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Game var items []*Game
for rows.Next() { for rows.Next() {
var i Game var i Game
if err := rows.Scan( if err := rows.Scan(
@@ -126,7 +78,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err 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' 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) row := q.db.QueryRowContext(ctx, getSnakeGame, id)
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -162,20 +114,20 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
&i.Score, &i.Score,
&i.SnakeSpeed, &i.SnakeSpeed,
) )
return i, err return &i, err
} }
const getSnakePlayers = `-- name: GetSnakePlayers :many 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 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) rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GamePlayer var items []*GamePlayer
for rows.Next() { for rows.Next() {
var i GamePlayer var i GamePlayer
if err := rows.Scan( if err := rows.Scan(
@@ -189,7 +141,7 @@ func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePla
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -214,20 +166,20 @@ ORDER BY g.updated_at DESC
` `
type GetUserActiveSnakeGamesRow struct { type GetUserActiveSnakeGamesRow struct {
ID string ID string `db:"id" json:"id"`
Status int64 Status int64 `db:"status" json:"status"`
GridWidth sql.NullInt64 GridWidth *int64 `db:"grid_width" json:"grid_width"`
GridHeight sql.NullInt64 GridHeight *int64 `db:"grid_height" json:"grid_height"`
UpdatedAt sql.NullTime 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) rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetUserActiveSnakeGamesRow var items []*GetUserActiveSnakeGamesRow
for rows.Next() { for rows.Next() {
var i GetUserActiveSnakeGamesRow var i GetUserActiveSnakeGamesRow
if err := rows.Scan( if err := rows.Scan(
@@ -239,7 +191,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -250,29 +202,43 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
return items, nil return items, nil
} }
const updateSnakeGame = `-- name: UpdateSnakeGame :exec const upsertSnakeGame = `-- name: UpsertSnakeGame :exec
UPDATE games 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)
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
WHERE id = ? AND game_type = 'snake' 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 { type UpsertSnakeGameParams struct {
Board string ID string `db:"id" json:"id"`
Status int64 Board string `db:"board" json:"board"`
WinnerUserID sql.NullString Status int64 `db:"status" json:"status"`
RematchGameID sql.NullString GridWidth *int64 `db:"grid_width" json:"grid_width"`
Score int64 GridHeight *int64 `db:"grid_height" json:"grid_height"`
ID string 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 { func (q *Queries) UpsertSnakeGame(ctx context.Context, arg UpsertSnakeGameParams) error {
_, err := q.db.ExecContext(ctx, updateSnakeGame, _, err := q.db.ExecContext(ctx, upsertSnakeGame,
arg.ID,
arg.Board, arg.Board,
arg.Status, arg.Status,
arg.GridWidth,
arg.GridHeight,
arg.GameMode,
arg.SnakeSpeed,
arg.WinnerUserID, arg.WinnerUserID,
arg.RematchGameID, arg.RematchGameID,
arg.Score, arg.Score,
arg.ID,
) )
return err return err
} }

View File

@@ -3,7 +3,7 @@
// sqlc v1.30.0 // sqlc v1.30.0
// source: users.sql // source: users.sql
package gen package repository
import ( import (
"context" "context"
@@ -16,12 +16,12 @@ RETURNING id, username, password_hash, created_at
` `
type CreateUserParams struct { type CreateUserParams struct {
ID string ID string `db:"id" json:"id"`
Username string Username string `db:"username" json:"username"`
PasswordHash string 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) row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
var i User var i User
err := row.Scan( err := row.Scan(
@@ -30,14 +30,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return &i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, created_at FROM users WHERE id = ? 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) row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User var i User
err := row.Scan( err := row.Scan(
@@ -46,14 +46,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return &i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM users WHERE username = ? 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) row := q.db.QueryRowContext(ctx, getUserByUsername, username)
var i User var i User
err := row.Scan( err := row.Scan(
@@ -62,5 +62,5 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return &i, err
} }

View File

@@ -5,5 +5,9 @@ sql:
schema: "migrations" schema: "migrations"
gen: gen:
go: go:
package: "gen" package: "repository"
out: "gen" out: "repository"
emit_db_tags: true
emit_json_tags: true
emit_result_struct_pointers: true
emit_pointers_for_null_types: true

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

135
features/auth/handlers.go Normal file
View File

@@ -0,0 +1,135 @@
package auth
import (
"database/sql"
"net/http"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/auth"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth/pages"
appsessions "github.com/ryanhamamura/games/sessions"
)
type LoginSignals struct {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // form input, not stored
}
type RegisterSignals struct {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // form input, not stored
Confirm string `json:"confirm"`
}
func HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := pages.LoginPage().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) {
if err := pages.RegisterPage().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) {
var signals LoginSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sse := datastar.NewSSE(w, r)
user, err := queries.GetUserByUsername(r.Context(), signals.Username)
if err == sql.ErrNoRows {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck
return
}
if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "An error occurred"}) //nolint:errcheck
return
}
if !auth.CheckPassword(signals.Password, user.PasswordHash) {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck
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
}
sse.Redirect(redirectURL) //nolint:errcheck
}
}
func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var signals RegisterSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sse := datastar.NewSSE(w, r)
if err := auth.ValidateUsername(signals.Username); err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": err.Error()}) //nolint:errcheck
return
}
if err := auth.ValidatePassword(signals.Password); err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": err.Error()}) //nolint:errcheck
return
}
if signals.Password != signals.Confirm {
sse.MarshalAndPatchSignals(map[string]any{"error": "Passwords do not match"}) //nolint:errcheck
return
}
hash, err := auth.HashPassword(signals.Password)
if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "An error occurred"}) //nolint:errcheck
return
}
user, err := queries.CreateUser(r.Context(), repository.CreateUserParams{
ID: uuid.New().String(),
Username: signals.Username,
PasswordHash: hash,
})
if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "Username already taken"}) //nolint:errcheck
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
}
sse.Redirect(redirectURL) //nolint:errcheck
}
}

View File

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

View File

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

16
features/auth/routes.go Normal file
View 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())
router.Get("/register", HandleRegisterPage())
router.Post("/auth/login", HandleLogin(queries, sessions))
router.Post("/auth/register", HandleRegister(queries, sessions))
}

View 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

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

326
features/c4game/handlers.go Normal file
View File

@@ -0,0 +1,326 @@
package c4game
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/sessions"
)
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
var c4ChatColors = map[int]string{
0: "#4a2a3a", // color 1 stored as slot 0
1: "#2a4545", // color 2 stored as slot 1
}
func c4ChatColor(slot int) string {
if c, ok := c4ChatColors[slot]; ok {
return c
}
return "#666"
}
func c4ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "c4",
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
Color: c4ChatColor,
}
}
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) 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 := chat.NewPersistentRoom(nil, "", queries, gameID)
if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
}
func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) 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
}
playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
chatCfg := c4ChatConfig(gameID)
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
patchAll := func() error {
myColor = gi.GetPlayerColor(playerID)
g := gi.GetGame()
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
sendPing := func() error {
return sse.PatchSignals([]byte(fmt.Sprintf(`{"lastPing":%d}`, time.Now().UnixMilli())))
}
// Send initial render and ping
if err := sendPing(); err != nil {
return
}
if err := patchAll(); err != nil {
return
}
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
if err != nil {
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh, chatSub, err := room.Subscribe()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
if err := sendPing(); err != nil {
return
}
case <-gameCh:
if err := patchAll(); err != nil {
return
}
case msg := <-chatCh:
chatMsg, _ := room.Receive(msg.Data)
err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("c4-chat-history"),
datastar.WithModeAppend(),
)
if 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, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) 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 := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, 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
}
}
}

View 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: '', lastPing: 0}"
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
>
@sharedcomponents.ConnectionIndicator()
@GameContent(g, myColor, messages, chatCfg)
</main>
}
}
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
<div id="game-content">
@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")
}
}

28
features/c4game/routes.go Normal file
View File

@@ -0,0 +1,28 @@
// Package c4game handles Connect 4 game routes, SSE event streaming, and chat.
package c4game
import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
)
func SetupRoutes(
router chi.Router,
store *connect4.Store,
nc *nats.Conn,
sessions *scs.SessionManager,
queries *repository.Queries,
) {
router.Route("/games/{id}", func(r chi.Router) {
r.Get("/", HandleGamePage(store, sessions, queries))
r.Get("/events", HandleGameEvents(store, nc, sessions, queries))
r.Post("/drop", HandleDropPiece(store, sessions))
r.Post("/chat", HandleSendChat(store, nc, sessions, queries))
r.Post("/join", HandleSetNickname(store, sessions))
r.Post("/rematch", HandleRematch(store, sessions))
})
}

View File

@@ -0,0 +1,75 @@
package components
import "github.com/starfederation/datastar-go/datastar"
templ BackToLobby() {
<a class="link text-sm opacity-70" href="/">&larr; Back</a>
}
templ StealthTitle(class string) {
<span class={ class }>
<span style="color:#4a2a3a">&#9679;</span>
<span style="color:#2a4545">&#9679;</span>
<span style="color:#4a2a3a">&#9679;</span>
<span style="color:#2a4545">&#9679;</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>
}
// ConnectionIndicator shows a small dot indicating SSE connection status.
// It requires a `lastPing` signal (unix ms timestamp) to be set by the server.
templ ConnectionIndicator() {
<div
id="connection-indicator"
class="fixed top-2 right-2 flex items-center gap-1 text-xs text-gray-500"
title="Connection status"
>
<span
class="w-2 h-2 rounded-full transition-colors duration-300"
data-class="{'bg-green-500': Date.now() - $lastPing < 20000, 'bg-red-500': Date.now() - $lastPing >= 20000}"
></span>
</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>
}

View File

@@ -0,0 +1,27 @@
package layouts
import (
"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/js/datastar.js"></script>
<link href="/assets/css/output.css" rel="stylesheet" type="text/css"/>
</head>
<body class="flex flex-col h-screen">
if config.Global.Environment == config.Dev {
<div data-init="@get('/reload', {retryMaxCount: 1000, retryInterval:20, retryMaxWaitMs:200})"></div>
}
{ children... }
<footer class="fixed bottom-1 right-2 text-xs text-gray-500">
{ version.Version }
</footer>
</body>
</html>
}

View File

@@ -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) }
>
&times;
</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)
}

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

177
features/lobby/handlers.go Normal file
View File

@@ -0,0 +1,177 @@
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
}
sse := datastar.NewSSE(w, r)
sse.ExecuteScript("window.location.href='/'") //nolint:errcheck
}
}

View File

@@ -0,0 +1,171 @@
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>
<button
type="button"
class="btn btn-ghost btn-sm"
data-on:click={ datastar.PostSSE("/logout") }
>
Logout
</button>
</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>
}
}

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

View 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"
}

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

View File

@@ -0,0 +1,337 @@
package snakegame
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake"
)
func snakeChatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}
func snakeChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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()
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
}
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, 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)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
chatCfg := snakeChatConfig(gameID)
// Chat room (multiplayer only)
var room *chat.Room
sg := si.GetGame()
if sg.Mode == snake.ModeMultiplayer {
room = chat.NewRoom(nc, snake.ChatSubject(gameID))
}
chatMessages := func() []chat.Message {
if room == nil {
return nil
}
return room.Messages()
}
patchAll := func() error {
si, ok = snakeStore.Get(gameID)
if !ok {
return fmt.Errorf("game not found")
}
mySlot = si.GetPlayerSlot(playerID)
sg = si.GetGame()
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
}
sendPing := func() error {
return sse.PatchSignals([]byte(fmt.Sprintf(`{"lastPing":%d}`, time.Now().UnixMilli())))
}
// Send initial render and ping
if err := sendPing(); err != nil {
return
}
if err := patchAll(); err != nil {
return
}
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
// Subscribe to game updates via NATS
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
if err != nil {
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
if room != nil {
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
}
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
if err := sendPing(); 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 msg := <-chatCh:
if msg == nil {
continue
}
chatMsg, _ := room.Receive(msg.Data)
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, nc *nats.Conn, 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 := chat.NewRoom(nc, snake.ChatSubject(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
}
}
}

View 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":"","lastPing":0}` }
data-init={ fmt.Sprintf("@get('/snake/%s/events',{requestCancellation:'disabled'})", gameID) }
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
tabindex="0"
>
@components.ConnectionIndicator()
@GameContent(sg, mySlot, messages, chatCfg, gameID)
</main>
}
}
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
<div id="game-content">
@components.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))
}
}

View 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/nats-io/nats.go"
"github.com/ryanhamamura/games/snake"
)
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
router.Route("/snake/{id}", func(r chi.Router) {
r.Get("/", HandleSnakePage(snakeStore, sessions))
r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions))
r.Post("/dir", HandleSetDirection(snakeStore, sessions))
r.Post("/chat", HandleSendChat(snakeStore, nc, sessions))
r.Post("/join", HandleSetNickname(snakeStore, sessions))
r.Post("/rematch", HandleRematch(snakeStore, sessions))
})
}

View File

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

236
go.mod
View File

@@ -1,52 +1,246 @@
module github.com/ryanhamamura/c4 module github.com/ryanhamamura/games
go 1.25.4 go 1.25.4
require ( 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/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.26.0 github.com/nats-io/nats-server/v2 v2.12.2
github.com/ryanhamamura/via v0.19.0 github.com/nats-io/nats.go v1.48.0
golang.org/x/crypto v0.47.0 github.com/pressly/goose/v3 v3.27.0
modernc.org/sqlite v1.44.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 ( 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/CAFxX/httpcompression v0.0.9 // indirect
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/alexedwards/scs/v2 v2.9.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/andybalholm/brotli v1.2.0 // indirect
github.com/antithesishq/antithesis-sdk-go v0.5.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/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/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/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/google/go-tpm v0.9.7 // indirect
github.com/hookenz/gotailwind/v4 v4.1.18 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/klauspost/compress v1.18.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/mattn/go-colorable v0.1.13 // 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-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/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/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/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/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // 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/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/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/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/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 go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect go.uber.org/zap v1.27.1 // indirect
golang.org/x/sync v0.18.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.40.0 // 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 golang.org/x/time v0.14.0 // indirect
maragu.dev/gomponents v1.2.0 // indirect golang.org/x/tools v0.42.0 // indirect
modernc.org/libc v1.67.4 // 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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
)

807
go.sum
View File

@@ -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 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= 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 h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= 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= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
@@ -7,51 +84,403 @@ 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.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 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 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs=
github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= 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/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 h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 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/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.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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o=
github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/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 h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= 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 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.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.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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/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.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.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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.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 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= 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 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= 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 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= 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= github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE=
@@ -64,81 +493,383 @@ 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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.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.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 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/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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/ryanhamamura/via v0.19.0 h1:mNQLOZAkforeiSVaoIUX5Mc1kDYvNKDhL33jV/0sArM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanhamamura/via v0.19.0/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 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/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w= 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.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.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.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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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.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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= 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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 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 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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= 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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 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.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= 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 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -147,9 +878,15 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.0 h1:YjCKJnzZde2mLVy0cMKTSL4PxCmbIguOq9lGp8ZvGOc= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.44.0/go.mod h1:2Dq41ir5/qri7QJJJKNZcP4UF7TsX/KNeykYgPDtGhE= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 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
View 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
}

126
logging/middleware.go Normal file
View File

@@ -0,0 +1,126 @@
package logging
import (
"fmt"
"net/http"
"time"
"github.com/ryanhamamura/games/config"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
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
}
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w}
next.ServeHTTP(rw, r)
status := rw.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")
}
})
}
}

839
main.go
View File

@@ -2,789 +2,122 @@ package main
import ( import (
"context" "context"
"crypto/md5"
"database/sql"
"embed" "embed"
"encoding/hex" "fmt"
"encoding/json" "log/slog"
"io/fs" "net"
"log" "net/http"
"os" "os/signal"
"sync" "syscall"
"time" "time"
"github.com/google/uuid" "github.com/ryanhamamura/games/config"
"github.com/joho/godotenv" "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"
"github.com/ryanhamamura/c4/auth" "github.com/go-chi/chi/v5"
"github.com/ryanhamamura/c4/db" "github.com/go-chi/chi/v5/middleware"
"github.com/ryanhamamura/c4/db/gen" "github.com/rs/zerolog/log"
"github.com/ryanhamamura/c4/game" "golang.org/x/sync/errgroup"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/c4/ui"
"github.com/ryanhamamura/via"
"github.com/ryanhamamura/via/h"
)
var (
store = game.NewGameStore()
snakeStore = snake.NewSnakeStore()
queries *gen.Queries
chatPersister *db.ChatPersister
) )
//go:embed assets //go:embed assets
var assets embed.FS 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() { func main() {
_ = godotenv.Load() ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := os.MkdirAll("data", 0o755); err != nil { cfg := config.Global
log.Fatal(err) logging.SetupLogger(cfg.Environment, cfg.LogLevel)
}
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)
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 { if err != nil {
log.Fatal(err) return fmt.Errorf("initializing database: %w", err)
} }
defer cleanupDB()
v := via.New() queries := repository.New(db.DB)
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,
})
subFS, _ := fs.Sub(assets, "assets") // Sessions
v.StaticFS("/assets/", subFS) sessionManager, cleanupSessions := sessions.SetupSessionManager(db.DB)
defer cleanupSessions()
store.SetPubSub(v.PubSub()) // NATS
snakeStore.SetPubSub(v.PubSub()) nc, cleanupNATS, err := appnats.SetupNATS(egctx)
// 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
}
if err != nil { if err != nil {
errorMsg.SetValue("An error occurred") return fmt.Errorf("setting up NATS: %w", err)
c.Sync()
return
}
if !auth.CheckPassword(password.String(), user.PasswordHash) {
errorMsg.SetValue("Invalid username or password")
c.Sync()
return
} }
defer cleanupNATS()
c.Session().RenewToken() // Game stores
c.Session().Set("user_id", user.ID) store := connect4.NewStore(queries)
c.Session().Set("username", user.Username) store.SetNotifyFunc(func(gameID string) {
c.Session().Set("nickname", user.Username) nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
returnURL := c.Session().GetString("return_url")
if returnURL != "" {
c.Session().Set("return_url", "")
c.Redirect(returnURL)
} else {
c.Redirect("/")
}
}) })
c.View(func() h.H { snakeStore := snake.NewSnakeStore(queries)
return ui.LoginView( snakeStore.SetNotifyFunc(func(gameID string) {
username.Bind(), nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
password.Bind(),
login.OnKeyDown("Enter"),
login.OnClick(),
errorMsg.String(),
)
})
}) })
// Register page // Router
v.Page("/register", func(c *via.Context) { logger := log.Logger
username := c.Signal("") r := chi.NewMux()
password := c.Signal("") r.Use(
confirm := c.Signal("") logging.RequestLogger(&logger, cfg.Environment),
errorMsg := c.Signal("") middleware.Recoverer,
sessionManager.LoadAndSave,
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),
) )
if g.Status == game.StatusWaitingForPlayer { router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets)
content = append(content, ui.InviteLink(g.ID))
// HTTP server
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
BaseContext: func(l net.Listener) context.Context {
return egctx
},
} }
mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")} eg.Go(func() error {
mainAttrs = append(mainAttrs, content...) err := srv.ListenAndServe()
return h.Main(mainAttrs...) if err != nil && err != http.ErrServerClosed {
}) return fmt.Errorf("server error: %w", err)
}
return nil
}) })
// Snake game page eg.Go(func() error {
v.Page("/snake/{game_id}", func(c *via.Context) { <-egctx.Done()
gameID := c.GetPathParam("game_id") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
sessionNickname := c.Session().GetString("nickname") defer cancel()
sessionUserID := c.Session().GetString("user_id") slog.Debug("shutting down server...")
return srv.Shutdown(shutdownCtx)
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() { return eg.Wait()
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)),
),
}
wrapperAttrs = append(wrapperAttrs, content...)
return h.Main(wrapperAttrs...)
})
})
v.Start()
} }

69
nats/nats.go Normal file
View 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
View File

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

72
router/router.go Normal file
View File

@@ -0,0 +1,72 @@
// Package router wires feature routes and middleware into the central chi mux.
package router
import (
"embed"
"io/fs"
"net/http"
"sync"
"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"
"github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/games/features/snakegame"
"github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar"
)
func SetupRoutes(
router chi.Router,
queries *repository.Queries,
sessions *scs.SessionManager,
nc *nats.Conn,
store *connect4.Store,
snakeStore *snake.SnakeStore,
assets embed.FS,
) {
// Static assets
subFS, _ := fs.Sub(assets, "assets")
router.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServerFS(subFS)))
// Hot-reload for development
if config.Global.Environment == config.Dev {
setupReload(router)
}
auth.SetupRoutes(router, queries, sessions)
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
c4game.SetupRoutes(router, store, nc, sessions, queries)
snakegame.SetupRoutes(router, snakeStore, nc, sessions)
}
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
View 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 = true
sessionManager.Cookie.SameSite = http.SameSiteLaxMode
slog.Info("session manager configured")
return sessionManager, cleanup
}
// GetPlayerID returns the current player's identity from the session.
// Authenticated users get their user UUID; guests get a random ID that
// is generated and persisted on first access.
func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID {
pid := sm.GetString(r.Context(), KeyPlayerID)
if pid == "" {
pid = player.GenerateID(8)
sm.Put(r.Context(), KeyPlayerID, pid)
}
if userID := sm.GetString(r.Context(), KeyUserID); userID != "" {
return player.ID(userID)
}
return player.ID(pid)
}
// GetUserID returns the authenticated user's UUID, or empty string for guests.
func GetUserID(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), KeyUserID)
}
// GetNickname returns the player's display name from the session.
func GetNickname(sm *scs.SessionManager, r *http.Request) string {
return sm.GetString(r.Context(), KeyNickname)
}

View File

@@ -1,3 +1,4 @@
// Package snake implements snake game logic, state management, and persistence.
package snake package snake
import "math/rand" import "math/rand"

View File

@@ -61,17 +61,15 @@ func (si *SnakeGameInstance) countdownPhase() {
si.initGame() si.initGame()
si.game.Status = StatusInProgress si.game.Status = StatusInProgress
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.save() //nolint:errcheck
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
return return
} }
if si.persister != nil { // No DB save during countdown ticks — state is transient
si.persister.SaveSnakeGame(si.game)
}
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
} }
@@ -98,6 +96,7 @@ func (si *SnakeGameInstance) gamePhase() {
defer ticker.Stop() defer ticker.Stop()
lastInput := time.Now() lastInput := time.Now()
lastSave := time.Now()
var moveAccum time.Duration var moveAccum time.Duration
for { for {
@@ -123,8 +122,8 @@ func (si *SnakeGameInstance) gamePhase() {
// Inactivity timeout // Inactivity timeout
if time.Since(lastInput) > inactivityLimit { if time.Since(lastInput) > inactivityLimit {
si.game.Status = StatusFinished si.game.Status = StatusFinished
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.save() //nolint:errcheck
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
@@ -175,13 +174,10 @@ func (si *SnakeGameInstance) gamePhase() {
alive := AliveCount(state) alive := AliveCount(state)
gameOver := false gameOver := false
if si.game.Mode == ModeSinglePlayer { if si.game.Mode == ModeSinglePlayer {
// Single player ends when the player dies (alive == 0)
if alive == 0 { if alive == 0 {
gameOver = true gameOver = true
// No winner in single player - just final score
} }
} else { } else {
// Multiplayer ends when 1 or fewer alive
if alive <= 1 { if alive <= 1 {
gameOver = true gameOver = true
winnerIdx := LastAlive(state) winnerIdx := LastAlive(state)
@@ -195,8 +191,10 @@ func (si *SnakeGameInstance) gamePhase() {
si.game.Status = StatusFinished si.game.Status = StatusFinished
} }
if si.persister != nil { // Throttle DB saves: persist on game over or every 2 seconds
si.persister.SaveSnakeGame(si.game) if si.queries != nil && (gameOver || time.Since(lastSave) >= 2*time.Second) {
si.save() //nolint:errcheck
lastSave = time.Now()
} }
si.gameMu.Unlock() si.gameMu.Unlock()

141
snake/persist.go Normal file
View 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
}

View File

@@ -1,48 +1,35 @@
package snake package snake
import ( import (
"crypto/rand" "context"
"encoding/hex"
"sync" "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 { type SnakeStore struct {
games map[string]*SnakeGameInstance games map[string]*SnakeGameInstance
gamesMu sync.RWMutex gamesMu sync.RWMutex
persister Persister queries *repository.Queries
pubsub PubSub notifyFunc func(gameID string)
} }
func NewSnakeStore() *SnakeStore { func NewSnakeStore(queries *repository.Queries) *SnakeStore {
return &SnakeStore{ return &SnakeStore{
games: make(map[string]*SnakeGameInstance), games: make(map[string]*SnakeGameInstance),
queries: queries,
} }
} }
func (ss *SnakeStore) SetPersister(p Persister) { func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
ss.persister = p ss.notifyFunc = f
}
func (ss *SnakeStore) SetPubSub(ps PubSub) {
ss.pubsub = ps
} }
func (ss *SnakeStore) makeNotify(gameID string) func() { func (ss *SnakeStore) makeNotify(gameID string) func() {
return func() { return func() {
if ss.pubsub != nil { if ss.notifyFunc != nil {
ss.pubsub.Publish("snake."+gameID, nil) ss.notifyFunc(gameID)
} }
} }
} }
@@ -51,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
if speed <= 0 { if speed <= 0 {
speed = DefaultSpeed speed = DefaultSpeed
} }
id := generateID(4) id := player.GenerateID(4)
sg := &SnakeGame{ sg := &SnakeGame{
ID: id, ID: id,
State: &GameState{ State: &GameState{
@@ -66,7 +53,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
si := &SnakeGameInstance{ si := &SnakeGameInstance{
game: sg, game: sg,
notify: ss.makeNotify(id), notify: ss.makeNotify(id),
persister: ss.persister, queries: ss.queries,
store: ss, store: ss,
} }
@@ -74,8 +61,8 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
ss.games[id] = si ss.games[id] = si
ss.gamesMu.Unlock() ss.gamesMu.Unlock()
if ss.persister != nil { if ss.queries != nil {
ss.persister.SaveSnakeGame(sg) si.save() //nolint:errcheck
} }
return si return si
@@ -90,16 +77,16 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
return si, true return si, true
} }
if ss.persister == nil { if ss.queries == nil {
return nil, false return nil, false
} }
sg, err := ss.persister.LoadSnakeGame(id) sg, err := loadSnakeGame(ss.queries, id)
if err != nil || sg == nil { if err != nil || sg == nil {
return nil, false return nil, false
} }
players, _ := ss.persister.LoadSnakePlayers(id) players, _ := loadSnakePlayers(ss.queries, id)
if sg.Players == nil { if sg.Players == nil {
sg.Players = make([]*Player, 8) sg.Players = make([]*Player, 8)
} }
@@ -112,7 +99,7 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
si = &SnakeGameInstance{ si = &SnakeGameInstance{
game: sg, game: sg,
notify: ss.makeNotify(id), notify: ss.makeNotify(id),
persister: ss.persister, queries: ss.queries,
store: ss, store: ss,
} }
@@ -133,8 +120,8 @@ func (ss *SnakeStore) Delete(id string) error {
si.Stop() si.Stop()
} }
if ss.persister != nil { if ss.queries != nil {
return ss.persister.DeleteSnakeGame(id) return ss.queries.DeleteSnakeGame(context.Background(), id)
} }
return nil return nil
} }
@@ -166,7 +153,7 @@ type SnakeGameInstance struct {
gameMu sync.RWMutex gameMu sync.RWMutex
pendingDirQueue [8][]Direction // queued directions per slot (max 3) pendingDirQueue [8][]Direction // queued directions per slot (max 3)
notify func() notify func()
persister Persister queries *repository.Queries
store *SnakeStore store *SnakeStore
stopCh chan struct{} stopCh chan struct{}
loopOnce sync.Once loopOnce sync.Once
@@ -185,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame {
return si.game.snapshot() return si.game.snapshot()
} }
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int { func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int {
si.gameMu.RLock() si.gameMu.RLock()
defer si.gameMu.RUnlock() defer si.gameMu.RUnlock()
for i, p := range si.game.Players { for i, p := range si.game.Players {
@@ -218,9 +205,9 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
player.Slot = slot player.Slot = slot
si.game.Players[slot] = player si.game.Players[slot] = player
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakePlayer(si.game.ID, player) si.savePlayer(player) //nolint:errcheck
si.persister.SaveSnakeGame(si.game) si.save() //nolint:errcheck
} }
si.notify() si.notify()
@@ -305,17 +292,11 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
} }
si.game.RematchGameID = &newID si.game.RematchGameID = &newID
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.save() //nolint:errcheck
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
return newSI return newSI
} }
func generateID(size int) string {
b := make([]byte, size)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -3,8 +3,19 @@ package snake
import ( import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/ryanhamamura/games/player"
) )
// SubjectPrefix is the NATS subject namespace for snake games.
const SubjectPrefix = "snake"
// GameSubject returns the NATS subject for game state updates.
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
// ChatSubject returns the NATS subject for chat messages.
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
type Direction int type Direction int
const ( const (
@@ -78,10 +89,8 @@ const (
StatusFinished StatusFinished
) )
type PlayerID string
type Player struct { type Player struct {
ID PlayerID ID player.ID
UserID *string UserID *string
Nickname string Nickname string
Slot int // 0-7 Slot int // 0-7
@@ -100,7 +109,7 @@ type SnakeGame struct {
Speed int // cells per second Speed int // cells per second
} }
// Speed presets // SpeedPreset defines a named speed option for the snake game.
type SpeedPreset struct { type SpeedPreset struct {
Name string Name string
Speed int Speed int
@@ -129,7 +138,7 @@ func (sg *SnakeGame) PlayerCount() int {
return count return count
} }
// Grid presets // GridPreset defines a named grid size option for the snake game.
type GridPreset struct { type GridPreset struct {
Name string Name string
Width int Width int
@@ -163,7 +172,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame {
return &cp return &cp
} }
// Snake colors (hex values for CSS) // SnakeColors are hex color values for CSS, indexed by player slot.
var SnakeColors = []string{ var SnakeColors = []string{
"#00b894", // 1: Green "#00b894", // 1: Green
"#e17055", // 2: Orange "#e17055", // 2: Orange

47
testutil/db.go Normal file
View 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
View 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
}

View File

@@ -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."),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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+"')"),
),
)
}

View File

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

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