Compare commits
46 Commits
v0.1.2
...
c77c491b64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c77c491b64 | ||
|
|
513467470c | ||
|
|
6976b773bd | ||
|
|
ac2492e7c1 | ||
| 65dc672186 | |||
|
|
1db6b2596e | ||
|
|
64b5d384ed | ||
|
|
235e4afbe3 | ||
|
|
649762e6c6 | ||
|
|
8780b7c9b1 | ||
| d77e4af1e2 | |||
|
|
718e0c55c9 | ||
|
|
dcf76bb773 | ||
|
|
4faf4f73b0 | ||
|
|
0808c4d972 | ||
|
|
42211439c9 | ||
|
|
fb6c0e3d90 | ||
|
|
2cfd42b606 | ||
|
|
6d43bdea16 | ||
|
|
c6885a069b | ||
|
|
38eb9ee398 | ||
|
|
f71acfc73e | ||
|
|
10de5d21ad | ||
|
|
7eadfbbb0c | ||
|
|
063b03ce25 | ||
| f47eb4cdf3 | |||
|
|
9a20467438 | ||
|
|
cb5458c9fc | ||
|
|
bc6488f063 | ||
| 9c3f659e96 | |||
|
|
2bea5bb489 | ||
|
|
4f1ee11fa3 | ||
|
|
8c6e5d24ac | ||
| 021215ed94 | |||
|
|
303c45cab1 | ||
|
|
587f392b8b | ||
|
|
5120eef776 | ||
|
|
fcc6b70e84 | ||
|
|
67d4dba37f | ||
|
|
afd8a3e9d0 | ||
|
|
2aa026b1d5 | ||
|
|
8c3b3fc6ea | ||
|
|
2df20c2840 | ||
|
|
6d4f3eb821 | ||
|
|
e68e4b48f5 | ||
|
|
91b5f2b80c |
@@ -1,10 +1,10 @@
|
||||
c4
|
||||
c4.db
|
||||
games
|
||||
games.db
|
||||
data/
|
||||
deploy/
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
assets/css/output.css
|
||||
c4-deploy-*.tar.gz
|
||||
c4-deploy-*_b64*.txt
|
||||
games-deploy-*.tar.gz
|
||||
games-deploy-*_b64*.txt
|
||||
|
||||
15
.env.example
15
.env.example
@@ -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
|
||||
|
||||
# Server port (defaults to 7331)
|
||||
# Server port. Defaults to 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
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
name: Deploy c4
|
||||
name: CI / Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
DEPLOY_DIR: /home/ryan/c4
|
||||
DEPLOY_DIR: /home/ryan/games
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Generate templ
|
||||
run: go tool templ generate
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Generate templ
|
||||
run: go tool templ generate
|
||||
|
||||
- name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
|
||||
|
||||
- name: Run linter
|
||||
run: golangci-lint run
|
||||
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
needs: [test, lint]
|
||||
runs-on: games
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -21,8 +57,6 @@ jobs:
|
||||
- name: Ensure data directory exists with correct ownership
|
||||
run: |
|
||||
mkdir -p $DEPLOY_DIR/data
|
||||
# UID 5 / GID 60 = games:games in the container (debian:bookworm-slim)
|
||||
sudo chown 5:60 $DEPLOY_DIR/data
|
||||
|
||||
- name: Rebuild and restart
|
||||
run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans
|
||||
|
||||
42
.gitignore
vendored
42
.gitignore
vendored
@@ -1,8 +1,36 @@
|
||||
c4
|
||||
c4.db
|
||||
data/
|
||||
.env
|
||||
# Allowlisting gitignore: ignore everything, then un-ignore what we track.
|
||||
# source: https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
|
||||
# Deploy artifacts
|
||||
c4-deploy-*.tar.gz
|
||||
c4-deploy-*_b64*.txt
|
||||
# Ignore everything
|
||||
*
|
||||
|
||||
# But not these files...
|
||||
!.gitignore
|
||||
|
||||
!*.go
|
||||
!*.templ
|
||||
!*.sql
|
||||
!go.sum
|
||||
!go.mod
|
||||
!Taskfile.yml
|
||||
!sqlc.yaml
|
||||
!.golangci.yml
|
||||
!.gitea/workflows/*.yml
|
||||
|
||||
!.env.example
|
||||
!LICENSE
|
||||
|
||||
!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
45
.golangci.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- staticcheck
|
||||
- gosec
|
||||
- bodyclose
|
||||
- sqlclosecheck
|
||||
- misspell
|
||||
- errname
|
||||
- copyloopvar
|
||||
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- "-ST1001" # dot imports
|
||||
- "-ST1003" # naming conventions
|
||||
gosec:
|
||||
excludes:
|
||||
- G104 # unhandled errors — redundant with errcheck
|
||||
- G107 # HTTP requests with variable URLs — expected in a web app
|
||||
- G115 # integer overflow conversion
|
||||
- G301 # directory permissions 0750 — 0755 is standard for data dirs
|
||||
- G404 # weak random — acceptable for game IDs and player IDs
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/ryanhamamura/games
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,29 +1,21 @@
|
||||
FROM golang:1.25.4-bookworm AS build
|
||||
FROM docker.io/golang:1.25.4-alpine AS build
|
||||
|
||||
RUN apk add --no-cache upx git
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go tool templ generate
|
||||
RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /c4 .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
VERSION=$(git describe --tags --always) && \
|
||||
COMMIT=$(git rev-parse --short HEAD) && \
|
||||
CGO_ENABLED=0 go build -ldflags="-s -X github.com/ryanhamamura/games/version.Version=$VERSION -X github.com/ryanhamamura/games/version.Commit=$COMMIT" -o /bin/games .
|
||||
RUN upx -9 -k /bin/games
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /c4 /usr/local/bin/c4
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir data && chown games:games data
|
||||
|
||||
USER games
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -qO /dev/null http://localhost:8080/
|
||||
|
||||
CMD ["c4"]
|
||||
FROM scratch
|
||||
ENV PORT=8080
|
||||
COPY --from=build /bin/games /
|
||||
ENTRYPOINT ["/games"]
|
||||
|
||||
85
Taskfile.yml
Normal file
85
Taskfile.yml
Normal 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
9
assets/js/datastar.js
Normal file
File diff suppressed because one or more lines are too long
7
assets/js/datastar.js.map
Normal file
7
assets/js/datastar.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
||||
// Package auth provides password hashing and verification using bcrypt.
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
||||
149
chat/chat.go
Normal file
149
chat/chat.go
Normal 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
|
||||
}
|
||||
73
chat/components/chat.templ
Normal file
73
chat/components/chat.templ
Normal file
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
|
||||
templ Chat(messages []chat.Message, cfg Config) {
|
||||
<div id={ cfg.CSSPrefix + "-chat" } class={ cfg.CSSPrefix + "-chat" }>
|
||||
<div class={ cfg.CSSPrefix + "-chat-history" }>
|
||||
for _, m := range messages {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<div class={ cfg.CSSPrefix + "-chat-input" } data-morph-ignore>
|
||||
if cfg.StopKeyPropagation {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Chat..."
|
||||
autocomplete="off"
|
||||
data-bind="chatMsg"
|
||||
data-on:keydown__stop={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", cfg.PostURL) }
|
||||
/>
|
||||
} else {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Chat..."
|
||||
autocomplete="off"
|
||||
data-bind="chatMsg"
|
||||
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", cfg.PostURL) }
|
||||
/>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("%s", cfg.PostURL) }
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
@chatAutoScroll(cfg.CSSPrefix)
|
||||
</div>
|
||||
}
|
||||
|
||||
script chatAutoScroll(cssPrefix string) {
|
||||
var el = document.querySelector('.' + cssPrefix + '-chat-history');
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||
.observe(el, {childList:true, subtree:true});
|
||||
}
|
||||
100
cmd/downloader/main.go
Normal file
100
cmd/downloader/main.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Asset directories, relative to project root.
|
||||
const (
|
||||
jsDir = "assets/js"
|
||||
cssDir = "assets/css"
|
||||
)
|
||||
|
||||
// files maps download URLs to local destination paths.
|
||||
var files = map[string]string{
|
||||
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js": jsDir + "/datastar.js",
|
||||
"https://raw.githubusercontent.com/starfederation/datastar/main/bundles/datastar.js.map": jsDir + "/datastar.js.map",
|
||||
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.mjs": cssDir + "/daisyui.mjs",
|
||||
"https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.mjs": cssDir + "/daisyui-theme.mjs",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("failure", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
dirs := []string{jsDir, cssDir}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return download(files)
|
||||
}
|
||||
|
||||
func download(files map[string]string) error {
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(files))
|
||||
|
||||
for url, dest := range files {
|
||||
wg.Go(func() {
|
||||
base := filepath.Base(dest)
|
||||
slog.Info("downloading...", "file", base, "url", url)
|
||||
if err := downloadFile(url, dest); err != nil {
|
||||
errCh <- fmt.Errorf("download %s: %w", base, err)
|
||||
} else {
|
||||
slog.Info("finished", "file", base)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
|
||||
var errs []error
|
||||
for err := range errCh {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func downloadFile(url, dest string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec,noctx // static URLs, simple tool
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: status %s", url, resp.Status)
|
||||
}
|
||||
|
||||
out, err := os.Create(dest) //nolint:gosec // paths are hardcoded constants
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
out.Close() //nolint:errcheck
|
||||
return fmt.Errorf("write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
76
config/config.go
Normal file
76
config/config.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package config provides build-tag-switched application configuration.
|
||||
// The global Config singleton is initialized at import time via init()
|
||||
// and can be overridden in tests with LoadForTest().
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
Dev Environment = "dev"
|
||||
Prod Environment = "prod"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment Environment
|
||||
Host string
|
||||
Port string
|
||||
LogLevel zerolog.Level
|
||||
AppURL string
|
||||
DBPath string
|
||||
}
|
||||
|
||||
var (
|
||||
Global *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func init() {
|
||||
once.Do(func() {
|
||||
Global = Load()
|
||||
})
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loadBase() *Config {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
slog.Warn("no .env file found, using environment variables and defaults")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Host: getEnv("HOST", "0.0.0.0"),
|
||||
Port: getEnv("PORT", "7331"),
|
||||
LogLevel: func() zerolog.Level {
|
||||
switch os.Getenv("LOG_LEVEL") {
|
||||
case "TRACE":
|
||||
return zerolog.TraceLevel
|
||||
case "DEBUG":
|
||||
return zerolog.DebugLevel
|
||||
case "INFO":
|
||||
return zerolog.InfoLevel
|
||||
case "WARN":
|
||||
return zerolog.WarnLevel
|
||||
case "ERROR":
|
||||
return zerolog.ErrorLevel
|
||||
default:
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
}(),
|
||||
AppURL: getEnv("APP_URL", "https://games.adriatica.io"),
|
||||
DBPath: getEnv("DB_PATH", "data/games.db"),
|
||||
}
|
||||
}
|
||||
9
config/config_dev.go
Normal file
9
config/config_dev.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build dev
|
||||
|
||||
package config
|
||||
|
||||
func Load() *Config {
|
||||
cfg := loadBase()
|
||||
cfg.Environment = Dev
|
||||
return cfg
|
||||
}
|
||||
9
config/config_prod.go
Normal file
9
config/config_prod.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !dev
|
||||
|
||||
package config
|
||||
|
||||
func Load() *Config {
|
||||
cfg := loadBase()
|
||||
cfg.Environment = Prod
|
||||
return cfg
|
||||
}
|
||||
19
config/config_test_helper.go
Normal file
19
config/config_test_helper.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// LoadForTest sets config.Global to safe defaults without reading
|
||||
// environment variables or .env files. Call this in TestMain or at the
|
||||
// top of tests that import packages which depend on config.Global.
|
||||
func LoadForTest() {
|
||||
Global = &Config{
|
||||
Environment: Dev,
|
||||
Host: "127.0.0.1",
|
||||
Port: "0",
|
||||
LogLevel: zerolog.WarnLevel,
|
||||
AppURL: "http://localhost:0",
|
||||
DBPath: ":memory:",
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package game
|
||||
// Package connect4 implements Connect 4 game logic, state management, and persistence.
|
||||
package connect4
|
||||
|
||||
// DropPiece attempts to drop a piece in the given column.
|
||||
// Returns (row placed, success).
|
||||
126
connect4/persist.go
Normal file
126
connect4/persist.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package connect4
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (gi *Instance) save() error {
|
||||
err := saveGame(gi.queries, gi.game)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (gi *Instance) savePlayer(p *Player, slot int) error {
|
||||
err := saveGamePlayer(gi.queries, gi.game.ID, p, slot)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// saveGame persists the game state via upsert.
|
||||
func saveGame(queries *repository.Queries, g *Game) error {
|
||||
var winnerUserID *string
|
||||
if g.Winner != nil && g.Winner.UserID != nil {
|
||||
winnerUserID = g.Winner.UserID
|
||||
}
|
||||
|
||||
var winningCells *string
|
||||
if wc := g.WinningCellsToJSON(); wc != "" {
|
||||
winningCells = &wc
|
||||
}
|
||||
|
||||
return queries.UpsertGame(context.Background(), repository.UpsertGameParams{
|
||||
ID: g.ID,
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
RematchGameID: g.RematchGameID,
|
||||
})
|
||||
}
|
||||
|
||||
func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error {
|
||||
var userID, guestPlayerID *string
|
||||
if p.UserID != nil {
|
||||
userID = p.UserID
|
||||
} else {
|
||||
id := string(p.ID)
|
||||
guestPlayerID = &id
|
||||
}
|
||||
|
||||
return queries.CreateGamePlayer(context.Background(), repository.CreateGamePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: p.Nickname,
|
||||
Color: int64(p.Color),
|
||||
Slot: int64(slot),
|
||||
})
|
||||
}
|
||||
|
||||
func loadGame(queries *repository.Queries, id string) (*Game, error) {
|
||||
row, err := queries.GetGame(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gameFromRow(row)
|
||||
}
|
||||
|
||||
func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error) {
|
||||
rows, err := queries.GetGamePlayers(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return playersFromRows(rows), nil
|
||||
}
|
||||
|
||||
func gameFromRow(row *repository.Game) (*Game, error) {
|
||||
g := &Game{
|
||||
ID: row.ID,
|
||||
CurrentTurn: int(row.CurrentTurn),
|
||||
Status: Status(row.Status),
|
||||
}
|
||||
|
||||
if err := g.BoardFromJSON(row.Board); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row.WinningCells != nil {
|
||||
_ = g.WinningCellsFromJSON(*row.WinningCells)
|
||||
}
|
||||
|
||||
if row.RematchGameID != nil {
|
||||
g.RematchGameID = row.RematchGameID
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func playersFromRows(rows []*repository.GamePlayer) []*Player {
|
||||
players := make([]*Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
p := &Player{
|
||||
Nickname: row.Nickname,
|
||||
Color: int(row.Color),
|
||||
}
|
||||
|
||||
if row.UserID != nil {
|
||||
p.UserID = row.UserID
|
||||
p.ID = player.ID(*row.UserID)
|
||||
} else if row.GuestPlayerID != nil {
|
||||
p.ID = player.ID(*row.GuestPlayerID)
|
||||
}
|
||||
|
||||
players = append(players, p)
|
||||
}
|
||||
return players
|
||||
}
|
||||
225
connect4/store.go
Normal file
225
connect4/store.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package connect4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
type PlayerSession struct {
|
||||
Player *Player
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
games map[string]*Instance
|
||||
gamesMu sync.RWMutex
|
||||
queries *repository.Queries
|
||||
notifyFunc func(gameID string)
|
||||
}
|
||||
|
||||
func NewStore(queries *repository.Queries) *Store {
|
||||
return &Store{
|
||||
games: make(map[string]*Instance),
|
||||
queries: queries,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) SetNotifyFunc(f func(gameID string)) {
|
||||
s.notifyFunc = f
|
||||
}
|
||||
|
||||
func (s *Store) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if s.notifyFunc != nil {
|
||||
s.notifyFunc(gameID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Create() *Instance {
|
||||
id := player.GenerateID(4)
|
||||
gi := NewInstance(id)
|
||||
gi.queries = s.queries
|
||||
gi.notify = s.makeNotify(id)
|
||||
s.gamesMu.Lock()
|
||||
s.games[id] = gi
|
||||
s.gamesMu.Unlock()
|
||||
|
||||
if s.queries != nil {
|
||||
gi.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
return gi
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Instance, bool) {
|
||||
s.gamesMu.RLock()
|
||||
gi, ok := s.games[id]
|
||||
s.gamesMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gi, true
|
||||
}
|
||||
|
||||
if s.queries == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
g, err := loadGame(s.queries, id)
|
||||
if err != nil || g == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := loadGamePlayers(s.queries, id)
|
||||
for _, p := range players {
|
||||
switch p.Color {
|
||||
case 1:
|
||||
g.Players[0] = p
|
||||
case 2:
|
||||
g.Players[1] = p
|
||||
}
|
||||
}
|
||||
|
||||
gi = &Instance{
|
||||
game: g,
|
||||
queries: s.queries,
|
||||
notify: s.makeNotify(id),
|
||||
}
|
||||
|
||||
s.gamesMu.Lock()
|
||||
s.games[id] = gi
|
||||
s.gamesMu.Unlock()
|
||||
|
||||
return gi, true
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.gamesMu.Lock()
|
||||
delete(s.games, id)
|
||||
s.gamesMu.Unlock()
|
||||
|
||||
if s.queries != nil {
|
||||
return s.queries.DeleteGame(context.Background(), id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
game *Game
|
||||
gameMu sync.RWMutex
|
||||
notify func()
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
func NewInstance(id string) *Instance {
|
||||
return &Instance{
|
||||
game: NewGame(id),
|
||||
notify: func() {},
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *Instance) ID() string {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game.ID
|
||||
}
|
||||
|
||||
func (gi *Instance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
var slot int
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1
|
||||
gi.game.Players[0] = ps.Player
|
||||
slot = 0
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
slot = 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.queries != nil {
|
||||
gi.savePlayer(ps.Player, slot) //nolint:errcheck
|
||||
gi.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
func (gi *Instance) GetGame() *Game {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game
|
||||
}
|
||||
|
||||
func (gi *Instance) GetPlayerColor(pid player.ID) int {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.ID == pid {
|
||||
return p.Color
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (gi *Instance) CreateRematch(s *Store) *Instance {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
newGI := s.Create()
|
||||
newID := newGI.ID()
|
||||
gi.game.RematchGameID = &newID
|
||||
|
||||
if gi.queries != nil {
|
||||
if err := gi.save(); err != nil {
|
||||
s.Delete(newID) //nolint:errcheck
|
||||
gi.game.RematchGameID = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return newGI
|
||||
}
|
||||
|
||||
func (gi *Instance) DropPiece(col int, playerColor int) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
row, ok := gi.game.DropPiece(col, playerColor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.game.CheckWin(row, col) {
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.Color == playerColor {
|
||||
gi.game.Winner = p
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if gi.game.CheckDraw() {
|
||||
// Status already set by CheckDraw
|
||||
} else {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
if gi.queries != nil {
|
||||
gi.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
@@ -1,20 +1,31 @@
|
||||
package game
|
||||
package connect4
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
type PlayerID string
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
// SubjectPrefix is the NATS subject namespace for connect4 games.
|
||||
const SubjectPrefix = "connect4"
|
||||
|
||||
// GameSubject returns the NATS subject for game state updates.
|
||||
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
|
||||
|
||||
// ChatSubject returns the NATS subject for chat messages.
|
||||
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
ID player.ID
|
||||
UserID *string // UUID for authenticated users, nil for guests
|
||||
Nickname string
|
||||
Color int // 1 = Red, 2 = Yellow
|
||||
}
|
||||
|
||||
type GameStatus int
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusWaitingForPlayer GameStatus = iota
|
||||
StatusWaitingForPlayer Status = iota
|
||||
StatusInProgress
|
||||
StatusWon
|
||||
StatusDraw
|
||||
@@ -25,7 +36,7 @@ type Game struct {
|
||||
Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow
|
||||
Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow)
|
||||
CurrentTurn int // 1 or 2 (matches player color)
|
||||
Status GameStatus
|
||||
Status Status
|
||||
Winner *Player
|
||||
WinningCells [][2]int // Coordinates of winning 4 cells for highlighting
|
||||
RematchGameID *string // ID of the rematch game, if one was created
|
||||
57
db/db.go
57
db/db.go
@@ -1,33 +1,70 @@
|
||||
// Package db handles SQLite database setup, pragma configuration, and
|
||||
// goose migrations.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
var MigrationFS embed.FS
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Init(dbPath string) error {
|
||||
func Init(dbPath string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
}
|
||||
|
||||
// busy_timeout must be first because the connection needs to block on
|
||||
// busy before WAL mode is set in case it hasn't been set already.
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
DB, err = goose.OpenDBWithDriver("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
DB.SetMaxOpenConns(1)
|
||||
|
||||
goose.SetBaseFS(migrations)
|
||||
if err := DB.Ping(); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close())
|
||||
}
|
||||
slog.Info("db connected", "db", dbPath)
|
||||
|
||||
sub, err := fs.Sub(MigrationFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("migrations sub fs: %w", err), DB.Close())
|
||||
}
|
||||
goose.SetBaseFS(sub)
|
||||
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
return nil, errors.Join(fmt.Errorf("setting goose dialect: %w", err), DB.Close())
|
||||
}
|
||||
if err := goose.Up(DB, "migrations"); err != nil {
|
||||
return err
|
||||
if err := goose.Up(DB, "."); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("running migrations: %w", err), DB.Close())
|
||||
}
|
||||
|
||||
return nil
|
||||
if _, err := DB.Exec("PRAGMA optimize"); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("pragma optimize: %w", err), DB.Close())
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
if _, err := DB.Exec("PRAGMA optimize(0x10002)"); err != nil {
|
||||
slog.Error("pragma optimize at shutdown", "error", err)
|
||||
}
|
||||
if err := DB.Close(); err != nil {
|
||||
slog.Error("closing database", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanup, nil
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package gen
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int64
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
CreatedAt sql.NullTime
|
||||
UpdatedAt sql.NullTime
|
||||
RematchGameID sql.NullString
|
||||
GameType string
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
MaxPlayers int64
|
||||
GameMode int64
|
||||
Score int64
|
||||
SnakeSpeed int64
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
12
db/migrations/007_sessions.sql
Normal file
12
db/migrations/007_sessions.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
expiry REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS sessions_expiry_idx;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
327
db/persister.go
327
db/persister.go
@@ -1,327 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
)
|
||||
|
||||
type GamePersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewGamePersister(q *gen.Queries) *GamePersister {
|
||||
return &GamePersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *GamePersister) SaveGame(g *game.Game) error {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := p.queries.GetGame(ctx, g.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = p.queries.CreateGame(ctx, gen.CreateGameParams{
|
||||
ID: g.ID,
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var winnerUserID sql.NullString
|
||||
if g.Winner != nil && g.Winner.UserID != nil {
|
||||
winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true}
|
||||
}
|
||||
|
||||
winningCells := sql.NullString{}
|
||||
if wc := g.WinningCellsToJSON(); wc != "" {
|
||||
winningCells = sql.NullString{String: wc, Valid: true}
|
||||
}
|
||||
|
||||
rematchGameID := sql.NullString{}
|
||||
if g.RematchGameID != nil {
|
||||
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
|
||||
Board: g.BoardToJSON(),
|
||||
CurrentTurn: int64(g.CurrentTurn),
|
||||
Status: int64(g.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
WinningCells: winningCells,
|
||||
RematchGameID: rematchGameID,
|
||||
ID: g.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
|
||||
ctx := context.Background()
|
||||
row, err := p.queries.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
g := &game.Game{
|
||||
ID: row.ID,
|
||||
CurrentTurn: int(row.CurrentTurn),
|
||||
Status: game.GameStatus(row.Status),
|
||||
}
|
||||
|
||||
if err := g.BoardFromJSON(row.Board); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if row.WinningCells.Valid {
|
||||
g.WinningCellsFromJSON(row.WinningCells.String)
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
g.RematchGameID = &row.RematchGameID.String
|
||||
}
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot int) error {
|
||||
ctx := context.Background()
|
||||
|
||||
var userID, guestPlayerID sql.NullString
|
||||
if player.UserID != nil {
|
||||
userID = sql.NullString{String: *player.UserID, Valid: true}
|
||||
} else {
|
||||
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.CreateGamePlayer(ctx, gen.CreateGamePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Color),
|
||||
Slot: int64(slot),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *GamePersister) LoadGamePlayers(gameID string) ([]*game.Player, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.queries.GetGamePlayers(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
players := make([]*game.Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
player := &game.Player{
|
||||
Nickname: row.Nickname,
|
||||
Color: int(row.Color),
|
||||
}
|
||||
|
||||
if row.UserID.Valid {
|
||||
player.UserID = &row.UserID.String
|
||||
player.ID = game.PlayerID(row.UserID.String)
|
||||
} else if row.GuestPlayerID.Valid {
|
||||
player.ID = game.PlayerID(row.GuestPlayerID.String)
|
||||
}
|
||||
|
||||
players = append(players, player)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func (p *GamePersister) DeleteGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteGame(ctx, id)
|
||||
}
|
||||
|
||||
// SnakePersister implements snake.Persister
|
||||
type SnakePersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewSnakePersister(q *gen.Queries) *SnakePersister {
|
||||
return &SnakePersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
|
||||
ctx := context.Background()
|
||||
|
||||
boardJSON := "{}"
|
||||
if sg.State != nil {
|
||||
boardJSON = sg.State.ToJSON()
|
||||
}
|
||||
|
||||
var gridWidth, gridHeight sql.NullInt64
|
||||
if sg.State != nil {
|
||||
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
|
||||
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
|
||||
}
|
||||
|
||||
_, err := p.queries.GetSnakeGame(ctx, sg.ID)
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = p.queries.CreateSnakeGame(ctx, gen.CreateSnakeGameParams{
|
||||
ID: sg.ID,
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
GridWidth: gridWidth,
|
||||
GridHeight: gridHeight,
|
||||
GameMode: int64(sg.Mode),
|
||||
SnakeSpeed: int64(sg.Speed),
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var winnerUserID sql.NullString
|
||||
if sg.Winner != nil && sg.Winner.UserID != nil {
|
||||
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
|
||||
}
|
||||
|
||||
rematchGameID := sql.NullString{}
|
||||
if sg.RematchGameID != nil {
|
||||
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.UpdateSnakeGame(ctx, gen.UpdateSnakeGameParams{
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
WinnerUserID: winnerUserID,
|
||||
RematchGameID: rematchGameID,
|
||||
Score: int64(sg.Score),
|
||||
ID: sg.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
|
||||
ctx := context.Background()
|
||||
row, err := p.queries.GetSnakeGame(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state, err := snake.GameStateFromJSON(row.Board)
|
||||
if err != nil {
|
||||
state = &snake.GameState{}
|
||||
}
|
||||
if row.GridWidth.Valid {
|
||||
state.Width = int(row.GridWidth.Int64)
|
||||
}
|
||||
if row.GridHeight.Valid {
|
||||
state.Height = int(row.GridHeight.Int64)
|
||||
}
|
||||
|
||||
sg := &snake.SnakeGame{
|
||||
ID: row.ID,
|
||||
State: state,
|
||||
Players: make([]*snake.Player, 8),
|
||||
Status: snake.Status(row.Status),
|
||||
Mode: snake.GameMode(row.GameMode),
|
||||
Score: int(row.Score),
|
||||
Speed: int(row.SnakeSpeed),
|
||||
}
|
||||
|
||||
if row.RematchGameID.Valid {
|
||||
sg.RematchGameID = &row.RematchGameID.String
|
||||
}
|
||||
|
||||
return sg, nil
|
||||
}
|
||||
|
||||
func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) error {
|
||||
ctx := context.Background()
|
||||
|
||||
var userID, guestPlayerID sql.NullString
|
||||
if player.UserID != nil {
|
||||
userID = sql.NullString{String: *player.UserID, Valid: true}
|
||||
} else {
|
||||
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
|
||||
}
|
||||
|
||||
return p.queries.CreateSnakePlayer(ctx, gen.CreateSnakePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Slot + 1),
|
||||
Slot: int64(player.Slot),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *SnakePersister) LoadSnakePlayers(gameID string) ([]*snake.Player, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.queries.GetSnakePlayers(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
players := make([]*snake.Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
player := &snake.Player{
|
||||
Nickname: row.Nickname,
|
||||
Slot: int(row.Slot),
|
||||
}
|
||||
|
||||
if row.UserID.Valid {
|
||||
player.UserID = &row.UserID.String
|
||||
player.ID = snake.PlayerID(row.UserID.String)
|
||||
} else if row.GuestPlayerID.Valid {
|
||||
player.ID = snake.PlayerID(row.GuestPlayerID.String)
|
||||
}
|
||||
|
||||
players = append(players, player)
|
||||
}
|
||||
|
||||
return players, nil
|
||||
}
|
||||
|
||||
func (p *SnakePersister) DeleteSnakeGame(id string) error {
|
||||
ctx := context.Background()
|
||||
return p.queries.DeleteSnakeGame(ctx, id)
|
||||
}
|
||||
|
||||
type ChatPersister struct {
|
||||
queries *gen.Queries
|
||||
}
|
||||
|
||||
func NewChatPersister(q *gen.Queries) *ChatPersister {
|
||||
return &ChatPersister{queries: q}
|
||||
}
|
||||
|
||||
func (p *ChatPersister) SaveChatMessage(gameID string, msg ui.C4ChatMessage) error {
|
||||
return p.queries.CreateChatMessage(context.Background(), gen.CreateChatMessageParams{
|
||||
GameID: gameID,
|
||||
Nickname: msg.Nickname,
|
||||
Color: int64(msg.Color),
|
||||
Message: msg.Message,
|
||||
CreatedAt: msg.Time,
|
||||
})
|
||||
}
|
||||
|
||||
func (p *ChatPersister) LoadChatMessages(gameID string) ([]ui.C4ChatMessage, error) {
|
||||
rows, err := p.queries.GetChatMessages(context.Background(), gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs := make([]ui.C4ChatMessage, len(rows))
|
||||
for i, r := range rows {
|
||||
msgs[i] = ui.C4ChatMessage{
|
||||
Nickname: r.Nickname,
|
||||
Color: int(r.Color),
|
||||
Message: r.Message,
|
||||
Time: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
// Query returns newest-first; reverse to oldest-first for display
|
||||
slices.Reverse(msgs)
|
||||
return msgs, nil
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
-- name: UpsertGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
|
||||
VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
current_turn = excluded.current_turn,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
winning_cells = excluded.winning_cells,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- name: GetGame :one
|
||||
SELECT * FROM games WHERE id = ?;
|
||||
|
||||
-- name: UpdateGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteGame :exec
|
||||
DELETE FROM games WHERE id = ?;
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||
RETURNING *;
|
||||
-- name: UpsertSnakeGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed, winner_user_id, rematch_game_id, score)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
score = excluded.score,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- name: GetSnakeGame :one
|
||||
SELECT * FROM games WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: UpdateSnakeGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
-- name: DeleteSnakeGame :exec
|
||||
DELETE FROM games WHERE id = ? AND game_type = 'snake';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// sqlc v1.30.0
|
||||
// source: chat.sql
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,11 +15,11 @@ VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateChatMessageParams struct {
|
||||
GameID string
|
||||
Nickname string
|
||||
Color int64
|
||||
Message string
|
||||
CreatedAt int64
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Message string `db:"message" json:"message"`
|
||||
CreatedAt int64 `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error {
|
||||
@@ -40,13 +40,13 @@ ORDER BY created_at DESC, id DESC
|
||||
LIMIT 50
|
||||
`
|
||||
|
||||
func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMessage, error) {
|
||||
func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]*ChatMessage, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getChatMessages, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ChatMessage
|
||||
var items []*ChatMessage
|
||||
for rows.Next() {
|
||||
var i ChatMessage
|
||||
if err := rows.Scan(
|
||||
@@ -59,7 +59,7 @@ func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMes
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -2,7 +2,7 @@
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -3,67 +3,25 @@
|
||||
// sqlc v1.30.0
|
||||
// source: games.sql
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createGame = `-- name: CreateGame :one
|
||||
INSERT INTO games (id, board, current_turn, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
|
||||
`
|
||||
|
||||
type CreateGameParams struct {
|
||||
ID string
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, createGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.CurrentTurn,
|
||||
arg.Status,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createGamePlayer = `-- name: CreateGamePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateGamePlayerParams struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
UserID *string `db:"user_id" json:"user_id"`
|
||||
GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Slot int64 `db:"slot" json:"slot"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error {
|
||||
@@ -91,13 +49,13 @@ const getActiveGames = `-- name: GetActiveGames :many
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'connect4' AND status < 2
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
func (q *Queries) GetActiveGames(ctx context.Context) ([]*Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
var items []*Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
@@ -120,7 +78,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -135,7 +93,7 @@ const getGame = `-- name: GetGame :one
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
func (q *Queries) GetGame(ctx context.Context, id string) (*Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, getGame, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
@@ -156,20 +114,20 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getGamePlayers = `-- name: GetGamePlayers :many
|
||||
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
|
||||
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]*GamePlayer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GamePlayer
|
||||
var items []*GamePlayer
|
||||
for rows.Next() {
|
||||
var i GamePlayer
|
||||
if err := rows.Scan(
|
||||
@@ -183,7 +141,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -201,13 +159,13 @@ WHERE gp.user_id = ?
|
||||
ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ([]Game, error) {
|
||||
func (q *Queries) GetGamesByUserID(ctx context.Context, userID *string) ([]*Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
var items []*Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
@@ -230,7 +188,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -257,21 +215,21 @@ ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
type GetUserActiveGamesRow struct {
|
||||
ID string
|
||||
Status int64
|
||||
CurrentTurn int64
|
||||
UpdatedAt sql.NullTime
|
||||
MyColor int64
|
||||
OpponentNickname sql.NullString
|
||||
ID string `db:"id" json:"id"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
CurrentTurn int64 `db:"current_turn" json:"current_turn"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
MyColor int64 `db:"my_color" json:"my_color"`
|
||||
OpponentNickname *string `db:"opponent_nickname" json:"opponent_nickname"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveGamesRow, error) {
|
||||
func (q *Queries) GetUserActiveGames(ctx context.Context, userID *string) ([]*GetUserActiveGamesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserActiveGamesRow
|
||||
var items []*GetUserActiveGamesRow
|
||||
for rows.Next() {
|
||||
var i GetUserActiveGamesRow
|
||||
if err := rows.Scan(
|
||||
@@ -284,7 +242,7 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -295,31 +253,38 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateGame = `-- name: UpdateGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
const upsertGame = `-- name: UpsertGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
|
||||
VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
current_turn = excluded.current_turn,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
winning_cells = excluded.winning_cells,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
type UpdateGameParams struct {
|
||||
Board string
|
||||
CurrentTurn int64
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
WinningCells sql.NullString
|
||||
RematchGameID sql.NullString
|
||||
ID string
|
||||
type UpsertGameParams struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Board string `db:"board" json:"board"`
|
||||
CurrentTurn int64 `db:"current_turn" json:"current_turn"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
|
||||
WinningCells *string `db:"winning_cells" json:"winning_cells"`
|
||||
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateGame,
|
||||
func (q *Queries) UpsertGame(ctx context.Context, arg UpsertGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.CurrentTurn,
|
||||
arg.Status,
|
||||
arg.WinnerUserID,
|
||||
arg.WinningCells,
|
||||
arg.RematchGameID,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
60
db/repository/models.go
Normal file
60
db/repository/models.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Message string `db:"message" json:"message"`
|
||||
CreatedAt int64 `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Board string `db:"board" json:"board"`
|
||||
CurrentTurn int64 `db:"current_turn" json:"current_turn"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
|
||||
WinningCells *string `db:"winning_cells" json:"winning_cells"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
|
||||
GameType string `db:"game_type" json:"game_type"`
|
||||
GridWidth *int64 `db:"grid_width" json:"grid_width"`
|
||||
GridHeight *int64 `db:"grid_height" json:"grid_height"`
|
||||
MaxPlayers int64 `db:"max_players" json:"max_players"`
|
||||
GameMode int64 `db:"game_mode" json:"game_mode"`
|
||||
Score int64 `db:"score" json:"score"`
|
||||
SnakeSpeed int64 `db:"snake_speed" json:"snake_speed"`
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
UserID *string `db:"user_id" json:"user_id"`
|
||||
GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Slot int64 `db:"slot" json:"slot"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `db:"token" json:"token"`
|
||||
Data []byte `db:"data" json:"data"`
|
||||
Expiry float64 `db:"expiry" json:"expiry"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
CreatedAt *time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -3,73 +3,25 @@
|
||||
// sqlc v1.30.0
|
||||
// source: snake_games.sql
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createSnakeGame = `-- name: CreateSnakeGame :one
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
|
||||
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
|
||||
`
|
||||
|
||||
type CreateSnakeGameParams struct {
|
||||
ID string
|
||||
Board string
|
||||
Status int64
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
GameMode int64
|
||||
SnakeSpeed int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, createSnakeGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.Status,
|
||||
arg.GridWidth,
|
||||
arg.GridHeight,
|
||||
arg.GameMode,
|
||||
arg.SnakeSpeed,
|
||||
)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Board,
|
||||
&i.CurrentTurn,
|
||||
&i.Status,
|
||||
&i.WinnerUserID,
|
||||
&i.WinningCells,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RematchGameID,
|
||||
&i.GameType,
|
||||
&i.GridWidth,
|
||||
&i.GridHeight,
|
||||
&i.MaxPlayers,
|
||||
&i.GameMode,
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createSnakePlayer = `-- name: CreateSnakePlayer :exec
|
||||
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateSnakePlayerParams struct {
|
||||
GameID string
|
||||
UserID sql.NullString
|
||||
GuestPlayerID sql.NullString
|
||||
Nickname string
|
||||
Color int64
|
||||
Slot int64
|
||||
GameID string `db:"game_id" json:"game_id"`
|
||||
UserID *string `db:"user_id" json:"user_id"`
|
||||
GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
|
||||
Nickname string `db:"nickname" json:"nickname"`
|
||||
Color int64 `db:"color" json:"color"`
|
||||
Slot int64 `db:"slot" json:"slot"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error {
|
||||
@@ -97,13 +49,13 @@ const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0
|
||||
`
|
||||
|
||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]*Game, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getActiveSnakeGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
var items []*Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
@@ -126,7 +78,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -141,7 +93,7 @@ const getSnakeGame = `-- name: GetSnakeGame :one
|
||||
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? AND game_type = 'snake'
|
||||
`
|
||||
|
||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (*Game, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSnakeGame, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
@@ -162,20 +114,20 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
|
||||
&i.Score,
|
||||
&i.SnakeSpeed,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getSnakePlayers = `-- name: GetSnakePlayers :many
|
||||
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? ORDER BY slot
|
||||
`
|
||||
|
||||
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) {
|
||||
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]*GamePlayer, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GamePlayer
|
||||
var items []*GamePlayer
|
||||
for rows.Next() {
|
||||
var i GamePlayer
|
||||
if err := rows.Scan(
|
||||
@@ -189,7 +141,7 @@ func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePla
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -214,20 +166,20 @@ ORDER BY g.updated_at DESC
|
||||
`
|
||||
|
||||
type GetUserActiveSnakeGamesRow struct {
|
||||
ID string
|
||||
Status int64
|
||||
GridWidth sql.NullInt64
|
||||
GridHeight sql.NullInt64
|
||||
UpdatedAt sql.NullTime
|
||||
ID string `db:"id" json:"id"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
GridWidth *int64 `db:"grid_width" json:"grid_width"`
|
||||
GridHeight *int64 `db:"grid_height" json:"grid_height"`
|
||||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveSnakeGamesRow, error) {
|
||||
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID *string) ([]*GetUserActiveSnakeGamesRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserActiveSnakeGamesRow
|
||||
var items []*GetUserActiveSnakeGamesRow
|
||||
for rows.Next() {
|
||||
var i GetUserActiveSnakeGamesRow
|
||||
if err := rows.Scan(
|
||||
@@ -239,7 +191,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -250,29 +202,43 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateSnakeGame = `-- name: UpdateSnakeGame :exec
|
||||
UPDATE games
|
||||
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND game_type = 'snake'
|
||||
const upsertSnakeGame = `-- name: UpsertSnakeGame :exec
|
||||
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed, winner_user_id, rematch_game_id, score)
|
||||
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
board = excluded.board,
|
||||
status = excluded.status,
|
||||
winner_user_id = excluded.winner_user_id,
|
||||
rematch_game_id = excluded.rematch_game_id,
|
||||
score = excluded.score,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
type UpdateSnakeGameParams struct {
|
||||
Board string
|
||||
Status int64
|
||||
WinnerUserID sql.NullString
|
||||
RematchGameID sql.NullString
|
||||
Score int64
|
||||
ID string
|
||||
type UpsertSnakeGameParams struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
Board string `db:"board" json:"board"`
|
||||
Status int64 `db:"status" json:"status"`
|
||||
GridWidth *int64 `db:"grid_width" json:"grid_width"`
|
||||
GridHeight *int64 `db:"grid_height" json:"grid_height"`
|
||||
GameMode int64 `db:"game_mode" json:"game_mode"`
|
||||
SnakeSpeed int64 `db:"snake_speed" json:"snake_speed"`
|
||||
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
|
||||
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
|
||||
Score int64 `db:"score" json:"score"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateSnakeGame,
|
||||
func (q *Queries) UpsertSnakeGame(ctx context.Context, arg UpsertSnakeGameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertSnakeGame,
|
||||
arg.ID,
|
||||
arg.Board,
|
||||
arg.Status,
|
||||
arg.GridWidth,
|
||||
arg.GridHeight,
|
||||
arg.GameMode,
|
||||
arg.SnakeSpeed,
|
||||
arg.WinnerUserID,
|
||||
arg.RematchGameID,
|
||||
arg.Score,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// sqlc v1.30.0
|
||||
// source: users.sql
|
||||
|
||||
package gen
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -16,12 +16,12 @@ RETURNING id, username, password_hash, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
ID string `db:"id" json:"id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -30,14 +30,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, created_at FROM users WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -46,14 +46,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, created_at FROM users WHERE username = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -62,5 +62,5 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
return &i, err
|
||||
}
|
||||
@@ -5,5 +5,9 @@ sql:
|
||||
schema: "migrations"
|
||||
gen:
|
||||
go:
|
||||
package: "gen"
|
||||
out: "gen"
|
||||
package: "repository"
|
||||
out: "repository"
|
||||
emit_db_tags: true
|
||||
emit_json_tags: true
|
||||
emit_result_struct_pointers: true
|
||||
emit_pointers_for_null_types: true
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the c4 binary to /opt/c4, then restart the service.
|
||||
# Deploy the games binary to /opt/games, then restart the service.
|
||||
# Works from the repo (builds first) or from an extracted tarball (pre-built binary).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
INSTALL_DIR="/opt/c4"
|
||||
BINARY="$ROOT_DIR/c4"
|
||||
INSTALL_DIR="/opt/games"
|
||||
BINARY="$ROOT_DIR/games"
|
||||
|
||||
# If Go is available and we have source, build fresh
|
||||
if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
|
||||
@@ -13,7 +13,7 @@ if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then
|
||||
(cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify)
|
||||
|
||||
echo "Building binary..."
|
||||
(cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o c4 .)
|
||||
(cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o games .)
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BINARY" ]]; then
|
||||
@@ -22,10 +22,10 @@ if [[ ! -f "$BINARY" ]]; then
|
||||
fi
|
||||
|
||||
echo "Installing to $INSTALL_DIR..."
|
||||
install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/c4"
|
||||
install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/games"
|
||||
|
||||
echo "Restarting service..."
|
||||
systemctl restart c4.service
|
||||
systemctl restart games.service
|
||||
|
||||
echo "Done. Status:"
|
||||
systemctl status c4.service --no-pager
|
||||
systemctl status games.service --no-pager
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[Unit]
|
||||
Description=C4 Game Lobby
|
||||
Description=Games Lobby
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=games
|
||||
Group=games
|
||||
WorkingDirectory=/opt/c4
|
||||
ExecStart=/opt/c4/c4
|
||||
WorkingDirectory=/opt/games
|
||||
ExecStart=/opt/games/games
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -17,7 +17,7 @@ Environment=PORT=8080
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/c4
|
||||
ReadWritePaths=/opt/games
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the c4 binary, bundle it with deploy files into a tarball,
|
||||
# Build the games binary, bundle it with deploy files into a tarball,
|
||||
# base64-encode it, and split into 25MB chunks for transfer.
|
||||
set -euo pipefail
|
||||
|
||||
@@ -7,14 +7,14 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_DIR"
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
TARBALL="c4-deploy-${TIMESTAMP}.tar.gz"
|
||||
BASE64_FILE="c4-deploy-${TIMESTAMP}_b64.txt"
|
||||
TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
|
||||
BASE64_FILE="games-deploy-${TIMESTAMP}_b64.txt"
|
||||
|
||||
#==============================================================================
|
||||
# Clean previous artifacts
|
||||
#==============================================================================
|
||||
echo "--- Cleaning old artifacts ---"
|
||||
rm -f ./c4 c4-deploy-*.tar.gz c4-deploy-*_b64*.txt
|
||||
rm -f ./games games-deploy-*.tar.gz games-deploy-*_b64*.txt
|
||||
|
||||
#==============================================================================
|
||||
# Build
|
||||
@@ -23,18 +23,18 @@ echo "--- Building CSS ---"
|
||||
go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify
|
||||
|
||||
echo "--- Building binary (linux/amd64) ---"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o c4 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o games .
|
||||
|
||||
#==============================================================================
|
||||
# Verify required files
|
||||
#==============================================================================
|
||||
echo "--- Verifying files ---"
|
||||
REQUIRED_FILES=(
|
||||
c4
|
||||
games
|
||||
deploy/setup.sh
|
||||
deploy/deploy.sh
|
||||
deploy/reassemble.sh
|
||||
deploy/c4.service
|
||||
deploy/games.service
|
||||
)
|
||||
for f in "${REQUIRED_FILES[@]}"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
@@ -48,12 +48,12 @@ done
|
||||
# Create tarball
|
||||
#==============================================================================
|
||||
echo "--- Creating tarball ---"
|
||||
tar -czf "/tmp/${TARBALL}" --transform 's,^,c4/,' \
|
||||
c4 \
|
||||
tar -czf "/tmp/${TARBALL}" --transform 's,^,games/,' \
|
||||
games \
|
||||
deploy/setup.sh \
|
||||
deploy/deploy.sh \
|
||||
deploy/reassemble.sh \
|
||||
deploy/c4.service
|
||||
deploy/games.service
|
||||
|
||||
mv "/tmp/${TARBALL}" .
|
||||
echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))"
|
||||
@@ -66,10 +66,10 @@ base64 "${TARBALL}" > "${BASE64_FILE}"
|
||||
echo " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))"
|
||||
|
||||
echo "--- Splitting into 25MB chunks ---"
|
||||
split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "c4-deploy-${TIMESTAMP}_b64_part"
|
||||
split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "games-deploy-${TIMESTAMP}_b64_part"
|
||||
rm -f "${BASE64_FILE}"
|
||||
|
||||
CHUNKS=(c4-deploy-${TIMESTAMP}_b64_part*.txt)
|
||||
CHUNKS=(games-deploy-${TIMESTAMP}_b64_part*.txt)
|
||||
echo " -> ${#CHUNKS[@]} chunk(s):"
|
||||
for chunk in "${CHUNKS[@]}"; do
|
||||
echo " $chunk ($(du -h "$chunk" | cut -f1))"
|
||||
@@ -83,5 +83,5 @@ echo "=== Package Complete ==="
|
||||
echo ""
|
||||
echo "Transfer the chunk files to the target server, then run:"
|
||||
echo " ./reassemble.sh"
|
||||
echo " cd ~/c4 && sudo ./deploy/setup.sh # first time only"
|
||||
echo " cd ~/c4 && sudo ./deploy/deploy.sh"
|
||||
echo " cd ~/games && sudo ./deploy/setup.sh # first time only"
|
||||
echo " cd ~/games && sudo ./deploy/deploy.sh"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reassembles base64 chunks and extracts the c4 deployment tarball.
|
||||
# Reassembles base64 chunks and extracts the games deployment tarball.
|
||||
# Expects chunk files in the current directory.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$HOME"
|
||||
|
||||
echo "=== C4 Deployment Reassembler ==="
|
||||
echo "=== Games Deployment Reassembler ==="
|
||||
echo "Working directory: $HOME"
|
||||
echo ""
|
||||
|
||||
@@ -14,10 +14,10 @@ echo ""
|
||||
#==============================================================================
|
||||
echo "--- Finding chunk files ---"
|
||||
|
||||
CHUNKS=($(ls -1 c4-deploy-*_b64_part*.txt 2>/dev/null | sort))
|
||||
CHUNKS=($(ls -1 games-deploy-*_b64_part*.txt 2>/dev/null | sort))
|
||||
|
||||
if [[ ${#CHUNKS[@]} -eq 0 ]]; then
|
||||
echo "ERROR: No chunk files found matching c4-deploy-*_b64_part*.txt"
|
||||
echo "ERROR: No chunk files found matching games-deploy-*_b64_part*.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -32,8 +32,8 @@ done
|
||||
echo ""
|
||||
echo "--- Reassembling chunks ---"
|
||||
|
||||
TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/c4-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
|
||||
TARBALL="c4-deploy-${TIMESTAMP}.tar.gz"
|
||||
TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/games-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/')
|
||||
TARBALL="games-deploy-${TIMESTAMP}.tar.gz"
|
||||
COMBINED="combined_b64.txt"
|
||||
|
||||
echo "Concatenating chunks..."
|
||||
@@ -58,12 +58,12 @@ fi
|
||||
echo ""
|
||||
echo "--- Archiving existing source ---"
|
||||
|
||||
if [[ -d c4 ]]; then
|
||||
rm -rf c4.bak
|
||||
mv c4 c4.bak
|
||||
echo " -> Moved c4 -> c4.bak"
|
||||
if [[ -d games ]]; then
|
||||
rm -rf games.bak
|
||||
mv games games.bak
|
||||
echo " -> Moved games -> games.bak"
|
||||
else
|
||||
echo " -> No existing c4 directory"
|
||||
echo " -> No existing games directory"
|
||||
fi
|
||||
|
||||
#==============================================================================
|
||||
@@ -73,7 +73,7 @@ echo ""
|
||||
echo "--- Extracting tarball ---"
|
||||
|
||||
tar -xzf "$TARBALL"
|
||||
echo " -> Extracted to ~/c4"
|
||||
echo " -> Extracted to ~/games"
|
||||
|
||||
#==============================================================================
|
||||
# Cleanup
|
||||
@@ -91,6 +91,6 @@ echo ""
|
||||
echo "=== Reassembly Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " cd ~/c4"
|
||||
echo " cd ~/games"
|
||||
echo " sudo ./deploy/setup.sh # first time only"
|
||||
echo " sudo ./deploy/deploy.sh"
|
||||
|
||||
@@ -10,20 +10,20 @@ fi
|
||||
|
||||
# Create system user if it doesn't exist
|
||||
if ! id -u games &>/dev/null; then
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir /opt/c4 --create-home games
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir /opt/games --create-home games
|
||||
echo "Created system user: games"
|
||||
else
|
||||
echo "User 'games' already exists"
|
||||
fi
|
||||
|
||||
# Ensure install directory exists with correct ownership
|
||||
install -d -o games -g games -m 755 /opt/c4
|
||||
install -d -o games -g games -m 755 /opt/c4/data
|
||||
install -d -o games -g games -m 755 /opt/games
|
||||
install -d -o games -g games -m 755 /opt/games/data
|
||||
|
||||
# Install systemd unit
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cp "$SCRIPT_DIR/c4.service" /etc/systemd/system/c4.service
|
||||
cp "$SCRIPT_DIR/games.service" /etc/systemd/system/games.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable c4.service
|
||||
systemctl enable games.service
|
||||
|
||||
echo "Setup complete. Run deploy.sh to build and start the service."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
c4:
|
||||
games:
|
||||
build: .
|
||||
container_name: c4
|
||||
container_name: games
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -11,4 +11,4 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data:/data
|
||||
|
||||
135
features/auth/handlers.go
Normal file
135
features/auth/handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
48
features/auth/pages/login.templ
Normal file
48
features/auth/pages/login.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
57
features/auth/pages/register.templ
Normal file
57
features/auth/pages/register.templ
Normal 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
16
features/auth/routes.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Package auth handles user authentication routes and handlers.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
)
|
||||
|
||||
func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) {
|
||||
router.Get("/login", HandleLoginPage())
|
||||
router.Get("/register", HandleRegisterPage())
|
||||
router.Post("/auth/login", HandleLogin(queries, sessions))
|
||||
router.Post("/auth/register", HandleRegister(queries, sessions))
|
||||
}
|
||||
65
features/c4game/components/board.templ
Normal file
65
features/c4game/components/board.templ
Normal file
@@ -0,0 +1,65 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ Board(g *connect4.Game, myColor int) {
|
||||
<div id="c4-board" class="board">
|
||||
for col := 0; col < 7; col++ {
|
||||
@column(g, col, myColor)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ column(g *connect4.Game, colIdx int, myColor int) {
|
||||
if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
|
||||
<div
|
||||
class="column clickable"
|
||||
data-on:click={ datastar.PostSSE("/games/%s/drop?col=%d", g.ID, colIdx) }
|
||||
>
|
||||
for row := 0; row < 6; row++ {
|
||||
@cell(g, row, colIdx)
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="column">
|
||||
for row := 0; row < 6; row++ {
|
||||
@cell(g, row, colIdx)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ cell(g *connect4.Game, row int, col int) {
|
||||
<div class={ cellClass(g, row, col) }></div>
|
||||
}
|
||||
|
||||
func cellClass(g *connect4.Game, row, col int) string {
|
||||
color := g.Board[row][col]
|
||||
activeTurn := 0
|
||||
if g.Status == connect4.StatusInProgress {
|
||||
activeTurn = g.CurrentTurn
|
||||
}
|
||||
|
||||
class := "cell"
|
||||
switch color {
|
||||
case 1:
|
||||
class += " red"
|
||||
case 2:
|
||||
class += " yellow"
|
||||
}
|
||||
if g.IsWinningCell(row, col) {
|
||||
class += " winning"
|
||||
}
|
||||
if color != 0 && color == activeTurn {
|
||||
class += " active-turn"
|
||||
}
|
||||
return class
|
||||
}
|
||||
|
||||
// suppress unused import
|
||||
var _ = fmt.Sprintf
|
||||
151
features/c4game/components/status.templ
Normal file
151
features/c4game/components/status.templ
Normal file
@@ -0,0 +1,151 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ StatusBanner(g *connect4.Game, myColor int) {
|
||||
<div id="c4-status" class={ statusClass(g, myColor) }>
|
||||
{ statusMessage(g, myColor) }
|
||||
if g.IsFinished() {
|
||||
if g.RematchGameID != nil {
|
||||
<a
|
||||
class="btn btn-sm bg-white text-gray-800 border-none ml-4"
|
||||
href={ templ.SafeURL("/games/" + *g.RematchGameID) }
|
||||
>
|
||||
Join Rematch
|
||||
</a>
|
||||
} else {
|
||||
<button
|
||||
class="btn btn-sm bg-white text-gray-800 border-none ml-4"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/games/%s/rematch", g.ID) }
|
||||
>
|
||||
Play again
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PlayerInfo(g *connect4.Game, myColor int) {
|
||||
<div id="c4-players" class="flex gap-8 mb-2">
|
||||
for _, info := range playerInfoPairs(g, myColor) {
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={ "player-chip " + info.ColorClass }></span>
|
||||
<span>{ info.Label }</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ InviteLink(gameID string) {
|
||||
<div class="mt-4 text-center">
|
||||
<p>Share this link with your opponent:</p>
|
||||
<div class="bg-base-200 p-4 rounded-lg font-mono break-all my-2">
|
||||
{ config.Global.AppURL + "/games/" + gameID }
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm mt-2"
|
||||
type="button"
|
||||
onclick={ copyToClipboard(config.Global.AppURL + "/games/" + gameID) }
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
script copyToClipboard(url string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
}
|
||||
|
||||
func statusClass(g *connect4.Game, myColor int) string {
|
||||
switch g.Status {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "alert bg-base-200 text-xl font-bold"
|
||||
case connect4.StatusInProgress:
|
||||
if g.CurrentTurn == myColor {
|
||||
return "alert alert-success text-xl font-bold"
|
||||
}
|
||||
return "alert bg-base-200 text-xl font-bold"
|
||||
case connect4.StatusWon:
|
||||
if g.Winner != nil && g.Winner.Color == myColor {
|
||||
return "alert alert-success text-xl font-bold"
|
||||
}
|
||||
return "alert alert-error text-xl font-bold"
|
||||
case connect4.StatusDraw:
|
||||
return "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
return "alert bg-base-200 text-xl font-bold"
|
||||
}
|
||||
|
||||
func statusMessage(g *connect4.Game, myColor int) string {
|
||||
switch g.Status {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent..."
|
||||
case connect4.StatusInProgress:
|
||||
if g.CurrentTurn == myColor {
|
||||
return "Your turn!"
|
||||
}
|
||||
return opponentName(g, myColor) + "'s turn"
|
||||
case connect4.StatusWon:
|
||||
if g.Winner != nil && g.Winner.Color == myColor {
|
||||
return "You win!"
|
||||
}
|
||||
if g.Winner != nil {
|
||||
return g.Winner.Nickname + " wins!"
|
||||
}
|
||||
return "Game over"
|
||||
case connect4.StatusDraw:
|
||||
return "It's a draw!"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opponentName(g *connect4.Game, myColor int) string {
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.Color != myColor {
|
||||
return p.Nickname
|
||||
}
|
||||
}
|
||||
return "Opponent"
|
||||
}
|
||||
|
||||
type playerInfoData struct {
|
||||
ColorClass string
|
||||
Label string
|
||||
}
|
||||
|
||||
func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData {
|
||||
var result []playerInfoData
|
||||
|
||||
var myName, oppName string
|
||||
var myClass, oppClass string
|
||||
|
||||
for _, p := range g.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
colorClass := "yellow"
|
||||
if p.Color == 1 {
|
||||
colorClass = "red"
|
||||
}
|
||||
if p.Color == myColor {
|
||||
myName = p.Nickname
|
||||
myClass = colorClass
|
||||
} else {
|
||||
oppName = p.Nickname
|
||||
oppClass = colorClass
|
||||
}
|
||||
}
|
||||
|
||||
if oppName == "" {
|
||||
oppName = "Waiting..."
|
||||
}
|
||||
|
||||
result = append(result, playerInfoData{ColorClass: myClass, Label: myName + " (You)"})
|
||||
result = append(result, playerInfoData{ColorClass: oppClass, Label: oppName})
|
||||
return result
|
||||
}
|
||||
307
features/c4game/handlers.go
Normal file
307
features/c4game/handlers.go
Normal file
@@ -0,0 +1,307 @@
|
||||
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))
|
||||
}
|
||||
|
||||
// Send initial render
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 <-gameCh:
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
case msg := <-chatCh:
|
||||
room.Receive(msg.Data)
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
colStr := r.URL.Query().Get("col")
|
||||
col, err := strconv.Atoi(colStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid column", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
if myColor == 0 {
|
||||
http.Error(w, "not in game", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
gi.DropPiece(col, myColor)
|
||||
datastar.NewSSE(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSendChat(store *connect4.Store, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
56
features/c4game/pages/game.templ
Normal file
56
features/c4game/pages/game.templ
Normal file
@@ -0,0 +1,56 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/games/chat"
|
||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/ryanhamamura/games/features/c4game/components"
|
||||
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||
"github.com/ryanhamamura/games/features/common/layouts"
|
||||
)
|
||||
|
||||
templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||
@layouts.Base("Connect 4") {
|
||||
<main
|
||||
class="flex flex-col items-center gap-4 p-4"
|
||||
data-signals="{chatMsg: ''}"
|
||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
||||
>
|
||||
@GameContent(g, myColor, messages, chatCfg)
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||
<div id="game-content">
|
||||
@sharedcomponents.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
28
features/c4game/routes.go
Normal 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))
|
||||
})
|
||||
}
|
||||
60
features/common/components/shared.templ
Normal file
60
features/common/components/shared.templ
Normal file
@@ -0,0 +1,60 @@
|
||||
package components
|
||||
|
||||
import "github.com/starfederation/datastar-go/datastar"
|
||||
|
||||
templ BackToLobby() {
|
||||
<a class="link text-sm opacity-70" href="/">← Back</a>
|
||||
}
|
||||
|
||||
templ StealthTitle(class string) {
|
||||
<span class={ class }>
|
||||
<span style="color:#4a2a3a">●</span>
|
||||
<span style="color:#2a4545">●</span>
|
||||
<span style="color:#4a2a3a">●</span>
|
||||
<span style="color:#2a4545">●</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
templ NicknamePrompt(returnPath string) {
|
||||
<main class="max-w-sm mx-auto mt-8 text-center" data-signals="{nickname: ''}">
|
||||
<h1 class="text-3xl font-bold">Join Game</h1>
|
||||
<p class="mb-4">Enter your nickname to join the game.</p>
|
||||
<form>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="nickname">Your Nickname</label>
|
||||
<input
|
||||
class="input input-bordered w-full"
|
||||
id="nickname"
|
||||
type="text"
|
||||
placeholder="Enter your nickname"
|
||||
data-bind="nickname"
|
||||
data-on:keydown={ "evt.key === 'Enter' && " + datastar.PostSSE("%s", returnPath) }
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</fieldset>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("%s", returnPath) }
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
27
features/common/layouts/base.templ
Normal file
27
features/common/layouts/base.templ
Normal 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>
|
||||
}
|
||||
109
features/lobby/components/gamelist.templ
Normal file
109
features/lobby/components/gamelist.templ
Normal file
@@ -0,0 +1,109 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ GameList(games []GameListItem) {
|
||||
if len(games) > 0 {
|
||||
<div class="mt-8 text-left">
|
||||
<h3 class="mb-4 text-center text-lg font-bold">Your Games</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
for _, g := range games {
|
||||
@gameListEntry(g)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ gameListEntry(g GameListItem) {
|
||||
<div class="flex items-center gap-2 p-2 bg-base-200 rounded-lg transition-colors hover:bg-base-300">
|
||||
<a
|
||||
href={ templ.SafeURL("/games/" + g.ID) }
|
||||
class="flex-1 flex justify-between items-center px-2 py-1 no-underline text-base-content"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-bold">{ opponentDisplay(g) }</span>
|
||||
<span class={ statusClass(g) }>{ statusText(g) }</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs opacity-60">{ formatTimeAgo(g.LastPlayed) }</span>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-square hover:btn-error"
|
||||
data-on:click={ datastar.DeleteSSE("/games/%s", g.ID) }
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
func statusText(g GameListItem) string {
|
||||
switch connect4.Status(g.Status) {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent"
|
||||
case connect4.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "Your turn!"
|
||||
}
|
||||
return "Opponent's turn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func statusClass(g GameListItem) string {
|
||||
switch connect4.Status(g.Status) {
|
||||
case connect4.StatusWaitingForPlayer:
|
||||
return "text-sm opacity-60"
|
||||
case connect4.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "text-sm text-success font-bold"
|
||||
}
|
||||
return "text-sm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func opponentDisplay(g GameListItem) string {
|
||||
if g.OpponentName == "" {
|
||||
return "Waiting for opponent..."
|
||||
}
|
||||
return "vs " + g.OpponentName
|
||||
}
|
||||
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if duration < time.Hour {
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", mins)
|
||||
}
|
||||
if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "yesterday"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
12
features/lobby/components/types.go
Normal file
12
features/lobby/components/types.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package components
|
||||
|
||||
import "time"
|
||||
|
||||
// GameListItem represents a connect4 game in the user's active game list.
|
||||
type GameListItem struct {
|
||||
ID string
|
||||
Status int
|
||||
OpponentName string
|
||||
IsMyTurn bool
|
||||
LastPlayed time.Time
|
||||
}
|
||||
177
features/lobby/handlers.go
Normal file
177
features/lobby/handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
171
features/lobby/pages/lobby.templ
Normal file
171
features/lobby/pages/lobby.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
20
features/lobby/pages/types.go
Normal file
20
features/lobby/pages/types.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package pages
|
||||
|
||||
import "github.com/ryanhamamura/games/features/lobby/components"
|
||||
|
||||
// SnakeGameListItem represents a joinable snake game in the lobby.
|
||||
type SnakeGameListItem struct {
|
||||
ID string
|
||||
Width int
|
||||
Height int
|
||||
PlayerCount int
|
||||
StatusLabel string
|
||||
}
|
||||
|
||||
// LobbyData holds all data needed to render the lobby page.
|
||||
type LobbyData struct {
|
||||
IsLoggedIn bool
|
||||
Username string
|
||||
UserGames []components.GameListItem
|
||||
ActiveSnakeGames []SnakeGameListItem
|
||||
}
|
||||
26
features/lobby/routes.go
Normal file
26
features/lobby/routes.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Package lobby handles the game lobby page, game creation, and navigation.
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func SetupRoutes(
|
||||
router chi.Router,
|
||||
queries *repository.Queries,
|
||||
sessions *scs.SessionManager,
|
||||
store *connect4.Store,
|
||||
snakeStore *snake.SnakeStore,
|
||||
) {
|
||||
router.Get("/", HandleLobbyPage(queries, sessions, snakeStore))
|
||||
|
||||
router.Post("/games", HandleCreateGame(store, sessions))
|
||||
router.Delete("/games/{id}", HandleDeleteGame(store, sessions))
|
||||
router.Post("/snake", HandleCreateSnakeGame(snakeStore, sessions))
|
||||
router.Post("/logout", HandleLogout(sessions))
|
||||
}
|
||||
113
features/snakegame/components/board.templ
Normal file
113
features/snakegame/components/board.templ
Normal file
@@ -0,0 +1,113 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
)
|
||||
|
||||
func cellSizeForGrid(width, height int) int {
|
||||
maxDim := width
|
||||
if height > maxDim {
|
||||
maxDim = height
|
||||
}
|
||||
switch {
|
||||
case maxDim <= 15:
|
||||
return 28
|
||||
case maxDim <= 20:
|
||||
return 24
|
||||
case maxDim <= 30:
|
||||
return 20
|
||||
case maxDim <= 40:
|
||||
return 16
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
|
||||
templ Board(sg *snake.SnakeGame) {
|
||||
<div
|
||||
id="snake-board"
|
||||
class="snake-board"
|
||||
if sg.State != nil && (sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished) {
|
||||
style={ fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", sg.State.Width) }
|
||||
}
|
||||
>
|
||||
if sg.State != nil && (sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished) {
|
||||
@boardCells(sg)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ boardCells(sg *snake.SnakeGame) {
|
||||
{{ state := sg.State }}
|
||||
{{ grid := buildGrid(state) }}
|
||||
{{ cellSize := cellSizeForGrid(state.Width, state.Height) }}
|
||||
for y := 0; y < state.Height; y++ {
|
||||
<div class="snake-row">
|
||||
for x := 0; x < state.Width; x++ {
|
||||
{{ ci := grid[y][x] }}
|
||||
if ci.snakeIdx == -2 {
|
||||
<div class="snake-cell snake-food" style={ fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize) }></div>
|
||||
} else if ci.snakeIdx >= 0 {
|
||||
{{ s := state.Snakes[ci.snakeIdx] }}
|
||||
{{ bg := snakeColor(ci.snakeIdx) }}
|
||||
if ci.isHead {
|
||||
if s.Alive {
|
||||
<div class="snake-cell snake-head" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;box-shadow:0 0 8px %s;", cellSize, cellSize, bg, bg) }></div>
|
||||
} else {
|
||||
<div class="snake-cell snake-head snake-dead" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;box-shadow:0 0 8px %s;", cellSize, cellSize, bg, bg) }></div>
|
||||
}
|
||||
} else {
|
||||
if s.Alive {
|
||||
<div class="snake-cell snake-body" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;", cellSize, cellSize, bg) }></div>
|
||||
} else {
|
||||
<div class="snake-cell snake-body snake-dead" style={ fmt.Sprintf("width:%dpx;height:%dpx;background:%s;", cellSize, cellSize, bg) }></div>
|
||||
}
|
||||
}
|
||||
} else {
|
||||
<div class="snake-cell" style={ fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize) }></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
func buildGrid(state *snake.GameState) [][]cellInfo {
|
||||
grid := make([][]cellInfo, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
grid[y] = make([]cellInfo, state.Width)
|
||||
for x := 0; x < state.Width; x++ {
|
||||
grid[y][x] = cellInfo{snakeIdx: -1}
|
||||
}
|
||||
}
|
||||
for fi := range state.Food {
|
||||
f := state.Food[fi]
|
||||
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
|
||||
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
|
||||
}
|
||||
}
|
||||
for si, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for bi, bp := range s.Body {
|
||||
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
|
||||
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
func snakeColor(idx int) string {
|
||||
if idx >= 0 && idx < len(snake.SnakeColors) {
|
||||
return snake.SnakeColors[idx]
|
||||
}
|
||||
return "#666"
|
||||
}
|
||||
137
features/snakegame/components/status.templ
Normal file
137
features/snakegame/components/status.templ
Normal file
@@ -0,0 +1,137 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
templ StatusBanner(sg *snake.SnakeGame, mySlot int, gameID string) {
|
||||
<div id="snake-status">
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
<div class="alert bg-base-200 text-xl font-bold">Ready?</div>
|
||||
} else {
|
||||
<div class="alert bg-base-200 text-xl font-bold">Waiting for players...</div>
|
||||
}
|
||||
case snake.StatusCountdown:
|
||||
{{ remaining := time.Until(sg.CountdownEnd) }}
|
||||
{{ secs := int(math.Ceil(remaining.Seconds())) }}
|
||||
if secs < 0 {
|
||||
{{ secs = 0 }}
|
||||
}
|
||||
<div class="alert alert-info text-xl font-bold">
|
||||
{ fmt.Sprintf("Starting in %d...", secs) }
|
||||
</div>
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) && sg.State.Snakes[mySlot] != nil && !sg.State.Snakes[mySlot].Alive {
|
||||
<div class="alert alert-error text-xl font-bold">You're out!</div>
|
||||
} else if sg.Mode == snake.ModeSinglePlayer {
|
||||
<div class="alert alert-success text-xl font-bold">
|
||||
{ fmt.Sprintf("Score: %d", sg.Score) }
|
||||
</div>
|
||||
} else {
|
||||
<div class="alert alert-success text-xl font-bold">Go!</div>
|
||||
}
|
||||
case snake.StatusFinished:
|
||||
@finishedBanner(sg, mySlot, gameID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ finishedBanner(sg *snake.SnakeGame, mySlot int, gameID string) {
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
<div class="alert alert-info text-xl font-bold">
|
||||
{ fmt.Sprintf("Game Over! Score: %d", sg.Score) }
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
} else if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
<div class="alert alert-success text-xl font-bold">
|
||||
You win!
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
} else {
|
||||
<div class="alert alert-error text-xl font-bold">
|
||||
{ sg.Winner.Nickname + " wins!" }
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="alert alert-warning text-xl font-bold">
|
||||
It's a draw!
|
||||
@rematchOrJoin(sg, gameID)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ rematchOrJoin(sg *snake.SnakeGame, gameID string) {
|
||||
if sg.RematchGameID != nil {
|
||||
<a class="btn btn-sm bg-white text-gray-800 border-none ml-4" href={ templ.SafeURL("/snake/" + *sg.RematchGameID) }>
|
||||
Join Rematch
|
||||
</a>
|
||||
} else {
|
||||
<button
|
||||
class="btn btn-sm bg-white text-gray-800 border-none ml-4"
|
||||
type="button"
|
||||
data-on:click={ datastar.PostSSE("/snake/%s/rematch", gameID) }
|
||||
>
|
||||
Play again
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
templ PlayerList(sg *snake.SnakeGame, mySlot int) {
|
||||
<div id="snake-players" class="flex flex-wrap gap-4 mb-2">
|
||||
for i, p := range sg.Players {
|
||||
if p != nil {
|
||||
<div class="flex items-center gap-2">
|
||||
<span style={ fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", snakeColor(i)) }></span>
|
||||
<span>
|
||||
{ p.Nickname }
|
||||
if i == mySlot {
|
||||
{ " (You)" }
|
||||
}
|
||||
</span>
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) && sg.State.Snakes[i] != nil {
|
||||
if sg.State.Snakes[i].Alive {
|
||||
<span class="text-sm opacity-60">
|
||||
{ fmt.Sprintf("(%d)", len(sg.State.Snakes[i].Body)) }
|
||||
</span>
|
||||
} else {
|
||||
<span class="text-sm opacity-40">(dead)</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ InviteLink(gameID string) {
|
||||
{{ fullURL := config.Global.AppURL + "/snake/" + gameID }}
|
||||
<div id="snake-invite" class="mt-4 text-center">
|
||||
<p>Share this link to invite players:</p>
|
||||
<div class="bg-base-200 p-4 rounded-lg font-mono break-all my-2">
|
||||
{ fullURL }
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm mt-2"
|
||||
type="button"
|
||||
onclick={ copyToClipboard(fullURL) }
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
script copyToClipboard(url string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
}
|
||||
316
features/snakegame/handlers.go
Normal file
316
features/snakegame/handlers.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package snakegame
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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))
|
||||
}
|
||||
|
||||
// Send initial render
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 <-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
|
||||
}
|
||||
room.Receive(msg.Data)
|
||||
if err := patchAll(); 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
|
||||
}
|
||||
}
|
||||
}
|
||||
83
features/snakegame/pages/game.templ
Normal file
83
features/snakegame/pages/game.templ
Normal file
@@ -0,0 +1,83 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/games/chat"
|
||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||
"github.com/ryanhamamura/games/features/common/components"
|
||||
"github.com/ryanhamamura/games/features/common/layouts"
|
||||
snakecomponents "github.com/ryanhamamura/games/features/snakegame/components"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
// keydownScript builds the inline JS for a single data-on:keydown handler
|
||||
// that dispatches WASD/arrow keys to direction POST endpoints.
|
||||
func keydownScript(gameID string) string {
|
||||
return fmt.Sprintf(
|
||||
"const k=evt.key;"+
|
||||
"if(k==='w'||k==='ArrowUp'){evt.preventDefault();%s}"+
|
||||
"else if(k==='s'||k==='ArrowDown'){evt.preventDefault();%s}"+
|
||||
"else if(k==='a'||k==='ArrowLeft'){evt.preventDefault();%s}"+
|
||||
"else if(k==='d'||k==='ArrowRight'){evt.preventDefault();%s}",
|
||||
datastar.PostSSE("/snake/%s/dir?d=0", gameID),
|
||||
datastar.PostSSE("/snake/%s/dir?d=1", gameID),
|
||||
datastar.PostSSE("/snake/%s/dir?d=2", gameID),
|
||||
datastar.PostSSE("/snake/%s/dir?d=3", gameID),
|
||||
)
|
||||
}
|
||||
|
||||
templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||
@layouts.Base("Snake") {
|
||||
<main
|
||||
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
||||
data-signals={ `{"chatMsg":""}` }
|
||||
data-init={ fmt.Sprintf("@get('/snake/%s/events',{requestCancellation:'disabled'})", gameID) }
|
||||
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
|
||||
tabindex="0"
|
||||
>
|
||||
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||
<div id="game-content">
|
||||
@components.BackToLobby()
|
||||
<h1 class="text-3xl font-bold">~~~~</h1>
|
||||
@snakecomponents.PlayerList(sg, mySlot)
|
||||
@snakecomponents.StatusBanner(sg, mySlot, gameID)
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
<div class="snake-game-area">
|
||||
@snakecomponents.Board(sg)
|
||||
@chatcomponents.Chat(messages, chatCfg)
|
||||
</div>
|
||||
} else {
|
||||
@snakecomponents.Board(sg)
|
||||
}
|
||||
} else if sg.Mode == snake.ModeMultiplayer {
|
||||
@chatcomponents.Chat(messages, chatCfg)
|
||||
}
|
||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||
@snakecomponents.InviteLink(gameID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ JoinPage(gameID string) {
|
||||
@layouts.Base("Snake - Join") {
|
||||
@components.GameJoinPrompt(
|
||||
fmt.Sprintf("/login?return_url=/snake/%s", gameID),
|
||||
fmt.Sprintf("/register?return_url=/snake/%s", gameID),
|
||||
fmt.Sprintf("/snake/%s", gameID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
templ NicknamePage(gameID string) {
|
||||
@layouts.Base("Snake - Join") {
|
||||
@components.NicknamePrompt(fmt.Sprintf("/snake/%s/join", gameID))
|
||||
}
|
||||
}
|
||||
21
features/snakegame/routes.go
Normal file
21
features/snakegame/routes.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Package snakegame handles snake game routes, SSE event streaming, and chat.
|
||||
package snakegame
|
||||
|
||||
import (
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/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))
|
||||
})
|
||||
}
|
||||
243
game/store.go
243
game/store.go
@@ -1,243 +0,0 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
}
|
||||
|
||||
type PlayerSession struct {
|
||||
Player *Player
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
SaveGame(g *Game) error
|
||||
LoadGame(id string) (*Game, error)
|
||||
SaveGamePlayer(gameID string, player *Player, slot int) error
|
||||
LoadGamePlayers(gameID string) ([]*Player, error)
|
||||
DeleteGame(id string) error
|
||||
}
|
||||
|
||||
type GameStore struct {
|
||||
games map[string]*GameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
pubsub PubSub
|
||||
}
|
||||
|
||||
func NewGameStore() *GameStore {
|
||||
return &GameStore{
|
||||
games: make(map[string]*GameInstance),
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPersister(p Persister) {
|
||||
gs.persister = p
|
||||
}
|
||||
|
||||
func (gs *GameStore) SetPubSub(ps PubSub) {
|
||||
gs.pubsub = ps
|
||||
}
|
||||
|
||||
func (gs *GameStore) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if gs.pubsub != nil {
|
||||
gs.pubsub.Publish("game."+gameID, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (gs *GameStore) Create() *GameInstance {
|
||||
id := GenerateID(4)
|
||||
gi := NewGameInstance(id)
|
||||
gi.persister = gs.persister
|
||||
gi.notify = gs.makeNotify(id)
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
if gs.persister != nil {
|
||||
gs.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
return gi
|
||||
}
|
||||
|
||||
func (gs *GameStore) Get(id string) (*GameInstance, bool) {
|
||||
gs.gamesMu.RLock()
|
||||
gi, ok := gs.games[id]
|
||||
gs.gamesMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
return gi, true
|
||||
}
|
||||
|
||||
if gs.persister == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
game, err := gs.persister.LoadGame(id)
|
||||
if err != nil || game == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := gs.persister.LoadGamePlayers(id)
|
||||
for _, p := range players {
|
||||
if p.Color == 1 {
|
||||
game.Players[0] = p
|
||||
} else if p.Color == 2 {
|
||||
game.Players[1] = p
|
||||
}
|
||||
}
|
||||
|
||||
gi = &GameInstance{
|
||||
game: game,
|
||||
persister: gs.persister,
|
||||
notify: gs.makeNotify(id),
|
||||
}
|
||||
|
||||
gs.gamesMu.Lock()
|
||||
gs.games[id] = gi
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
return gi, true
|
||||
}
|
||||
|
||||
func (gs *GameStore) Delete(id string) error {
|
||||
gs.gamesMu.Lock()
|
||||
delete(gs.games, id)
|
||||
gs.gamesMu.Unlock()
|
||||
|
||||
if gs.persister != nil {
|
||||
return gs.persister.DeleteGame(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateID(size int) string {
|
||||
b := make([]byte, size)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
type GameInstance struct {
|
||||
game *Game
|
||||
gameMu sync.RWMutex
|
||||
notify func()
|
||||
persister Persister
|
||||
}
|
||||
|
||||
func NewGameInstance(id string) *GameInstance {
|
||||
return &GameInstance{
|
||||
game: NewGame(id),
|
||||
notify: func() {},
|
||||
}
|
||||
}
|
||||
|
||||
func (gi *GameInstance) ID() string {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game.ID
|
||||
}
|
||||
|
||||
func (gi *GameInstance) Join(ps *PlayerSession) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
var slot int
|
||||
if gi.game.Players[0] == nil {
|
||||
ps.Player.Color = 1
|
||||
gi.game.Players[0] = ps.Player
|
||||
slot = 0
|
||||
} else if gi.game.Players[1] == nil {
|
||||
ps.Player.Color = 2
|
||||
gi.game.Players[1] = ps.Player
|
||||
gi.game.Status = StatusInProgress
|
||||
slot = 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot)
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
func (gi *GameInstance) GetGame() *Game {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
return gi.game
|
||||
}
|
||||
|
||||
func (gi *GameInstance) GetPlayerColor(pid PlayerID) int {
|
||||
gi.gameMu.RLock()
|
||||
defer gi.gameMu.RUnlock()
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.ID == pid {
|
||||
return p.Color
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
if !gi.game.IsFinished() || gi.game.RematchGameID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
newGI := gs.Create()
|
||||
newID := newGI.ID()
|
||||
gi.game.RematchGameID = &newID
|
||||
|
||||
if gi.persister != nil {
|
||||
if err := gi.persister.SaveGame(gi.game); err != nil {
|
||||
gs.Delete(newID)
|
||||
gi.game.RematchGameID = nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return newGI
|
||||
}
|
||||
|
||||
func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
|
||||
gi.gameMu.Lock()
|
||||
defer gi.gameMu.Unlock()
|
||||
|
||||
row, ok := gi.game.DropPiece(col, playerColor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if gi.game.CheckWin(row, col) {
|
||||
for _, p := range gi.game.Players {
|
||||
if p != nil && p.Color == playerColor {
|
||||
gi.game.Winner = p
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if gi.game.CheckDraw() {
|
||||
// Status already set by CheckDraw
|
||||
} else {
|
||||
gi.game.SwitchTurn()
|
||||
}
|
||||
|
||||
if gi.persister != nil {
|
||||
gi.persister.SaveGame(gi.game)
|
||||
}
|
||||
|
||||
gi.notify()
|
||||
return true
|
||||
}
|
||||
236
go.mod
236
go.mod
@@ -1,52 +1,246 @@
|
||||
module github.com/ryanhamamura/c4
|
||||
module github.com/ryanhamamura/games
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.1001
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/delaneyj/toolbelt v0.9.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/ryanhamamura/via v0.21.2
|
||||
golang.org/x/crypto v0.47.0
|
||||
modernc.org/sqlite v1.44.0
|
||||
github.com/nats-io/nats-server/v2 v2.12.2
|
||||
github.com/nats-io/nats.go v1.48.0
|
||||
github.com/pressly/goose/v3 v3.27.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/starfederation/datastar-go v1.1.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1 // indirect
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/storage v1.58.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect
|
||||
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||
github.com/Ladicle/tabwriter v1.0.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/delaneyj/toolbelt v0.9.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chainguard-dev/git-urls v1.0.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.5.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cubicdaiya/gonp v1.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dominikbraun/graph v0.23.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-task/task/v3 v3.48.0 // indirect
|
||||
github.com/go-task/template v0.2.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/cel-go v0.26.1 // indirect
|
||||
github.com/google/go-tpm v0.9.7 // indirect
|
||||
github.com/hookenz/gotailwind/v4 v4.1.18 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-getter v1.8.4 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hookenz/gotailwind/v4 v4.2.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mfridman/xflag v0.1.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.6 // indirect
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/natefinch/atomic v1.0.1 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.0 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.12.2 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
|
||||
github.com/pingcap/log v1.1.0 // indirect
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/riza-io/grpc-go v0.2.0 // indirect
|
||||
github.com/sajari/fuzzy v1.0.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/starfederation/datastar-go v1.0.3 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.9.1 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/sqlc-dev/sqlc v1.30.0 // indirect
|
||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/tetratelabs/wazero v1.9.0 // indirect
|
||||
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc // indirect
|
||||
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8 // indirect
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vertica/vertica-sql-go v1.3.5 // indirect
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 // indirect
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
maragu.dev/gomponents v1.2.0 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/api v0.256.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 // indirect
|
||||
mvdan.cc/sh/v3 v3.12.1-0.20260124232039-e74afc18e65b // indirect
|
||||
)
|
||||
|
||||
tool github.com/hookenz/gotailwind/v4
|
||||
tool (
|
||||
github.com/a-h/templ/cmd/templ
|
||||
github.com/air-verse/air
|
||||
github.com/go-task/task/v3/cmd/task
|
||||
github.com/hookenz/gotailwind/v4
|
||||
github.com/pressly/goose/v3/cmd/goose
|
||||
github.com/sqlc-dev/sqlc/cmd/sqlc
|
||||
)
|
||||
|
||||
807
go.sum
807
go.sum
@@ -1,5 +1,82 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=
|
||||
charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD7k=
|
||||
charm.land/bubbletea/v2 v2.0.0-rc.2/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7 h1:059k1h5vvZ4ASinki9nmBguxu9Rq0UDDSa6q8LOUphk=
|
||||
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106192539-4b304240aab7/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
|
||||
cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
|
||||
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg=
|
||||
github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.23.0 h1:u/Orux1J0eLuZDeQ44froV8smumheieI0EofhbyKhhk=
|
||||
github.com/alecthomas/chroma/v2 v2.23.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo=
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
@@ -7,51 +84,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.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
|
||||
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
|
||||
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
|
||||
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
|
||||
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
|
||||
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
|
||||
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
|
||||
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
|
||||
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
|
||||
github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5kujw=
|
||||
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
|
||||
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
|
||||
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
|
||||
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
|
||||
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
|
||||
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
|
||||
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
|
||||
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
|
||||
github.com/bep/lazycache v0.8.0/go.mod h1:BQ5WZepss7Ko91CGdWz8GQZi/fFnCcyWupv8gyTeKwk=
|
||||
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
|
||||
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
|
||||
github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE=
|
||||
github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro=
|
||||
github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
|
||||
github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
|
||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ=
|
||||
github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o=
|
||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38 h1:7Rs87fbKJoIIxsQS8YKJYGYa0tlsDwwb0twQjV1KB+g=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251116181749-377898bcce38/go.mod h1:6lfcr3MNP+kZR25sF1nQwJFuQnNYBlFy3PGX5rvslXc=
|
||||
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
|
||||
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
|
||||
github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
|
||||
github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/delaneyj/toolbelt v0.9.1 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o=
|
||||
github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
|
||||
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/go-sysinfo v1.8.1/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM=
|
||||
github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q=
|
||||
github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
|
||||
github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU=
|
||||
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
|
||||
github.com/elastic/go-windows v1.0.2/go.mod h1:bGcDpBzXgYSqM0Gx3DM4+UxFj300SZLixie9u9ixLM8=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
|
||||
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-task/task/v3 v3.48.0 h1:HEim5OOpgmob5ONfq7ji3QHUyJdcwqL5ctOT5CPWCzA=
|
||||
github.com/go-task/task/v3 v3.48.0/go.mod h1:ChDoJV0k919miEJJu1yJ846tg+4Ivv9ZE/1YwQXvIRY=
|
||||
github.com/go-task/template v0.2.0 h1:xW7ek0o65FUSTbKcSNeg2Vyf/I7wYXFgLUznptvviBE=
|
||||
github.com/go-task/template v0.2.0/go.mod h1:dbdoUb6qKnHQi1y6o+IdIrs0J4o/SEhSTA6bbzZmdtc=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
|
||||
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
|
||||
github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
|
||||
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
|
||||
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
|
||||
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
|
||||
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
|
||||
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
|
||||
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
|
||||
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
|
||||
github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4=
|
||||
github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo=
|
||||
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
||||
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
|
||||
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 h1:0HADrxxqaQkGycO1JoUUA+B4FnIkuo8d2bz/hSaTFFQ=
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70/go.mod h1:fm2FdDCzJdtbXF7WKAMvBb5NEPouXPHFbGNYs9ShFns=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-getter v1.8.4 h1:hGEd2xsuVKgwkMtPVufq73fAmZU/x65PPcqH3cb0D9A=
|
||||
github.com/hashicorp/go-getter v1.8.4/go.mod h1:x27pPGSg9kzoB147QXI8d/nDvp2IgYGcwuRjpaXE9Yg=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE=
|
||||
github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hookenz/gotailwind/v4 v4.2.1 h1:FpZLtAAbHH7wMvyGYT+01vTLFITGMGZGMtEbp7dd2dM=
|
||||
github.com/hookenz/gotailwind/v4 v4.2.1/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
|
||||
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
|
||||
github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
|
||||
github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
|
||||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
|
||||
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
|
||||
github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE=
|
||||
@@ -64,81 +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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
|
||||
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
|
||||
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
|
||||
github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
|
||||
github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
|
||||
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
||||
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM=
|
||||
github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
|
||||
github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/ryanhamamura/via v0.21.2 h1:osR6peY/mZSl9SNPeEv6IvzGU0akkQfQzQJgA74+7mk=
|
||||
github.com/ryanhamamura/via v0.21.2/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
|
||||
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
|
||||
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
|
||||
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
|
||||
github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
|
||||
github.com/starfederation/datastar-go v1.1.0 h1:UVOYpbNfKPfrEq3MBOa1FRPO/YsxxcIduUxUTJiEQbQ=
|
||||
github.com/starfederation/datastar-go v1.1.0/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
|
||||
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
|
||||
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
|
||||
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
|
||||
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
||||
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
|
||||
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
|
||||
github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
|
||||
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8 h1:cq+DjLAjz3ZPwh0+G571O/jMH0c0DzReDPLjQGL2/BA=
|
||||
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8/go.mod h1:JNauIV2zopCBv/6o+umxcT3bKe8YUqYJaTZQYSYpKss=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
|
||||
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||
github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI=
|
||||
github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 h1:kUXMT/fM/DpDT66WQgRUf3I8VOAWjypkMf52W5PChwA=
|
||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 h1:OfHS9ZkZgCy6y/CJ9N8123DXrgaY2BPxWsQiQ8e3wC8=
|
||||
github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
||||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc=
|
||||
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc=
|
||||
maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -147,9 +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/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.0 h1:YjCKJnzZde2mLVy0cMKTSL4PxCmbIguOq9lGp8ZvGOc=
|
||||
modernc.org/sqlite v1.44.0/go.mod h1:2Dq41ir5/qri7QJJJKNZcP4UF7TsX/KNeykYgPDtGhE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR3eLHTH1CmR3BcU6TriIMiXjE=
|
||||
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997/go.mod h1:Qy/zdaMDxq9sT72Gi43K3gsV+TtTohyDO3f1cyBVwuo=
|
||||
mvdan.cc/sh/v3 v3.12.1-0.20260124232039-e74afc18e65b h1:PUPnLxbDzRO9kg/03l7TZk7+ywTv7FxmOhDHOtOdOtk=
|
||||
mvdan.cc/sh/v3 v3.12.1-0.20260124232039-e74afc18e65b/go.mod h1:mencVHx2sy9XZG5wJbCA9nRUOE3zvMtoRXOmXMxH7sc=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
41
logging/log.go
Normal file
41
logging/log.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Package logging configures zerolog and provides HTTP request logging middleware.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
stdlog "log"
|
||||
"os"
|
||||
|
||||
"github.com/ryanhamamura/games/config"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
func SetupLogger(env config.Environment, level zerolog.Level) *zerolog.Logger {
|
||||
zerolog.ErrorStackFieldName = "stack_trace"
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
|
||||
zerolog.SetGlobalLevel(level)
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
|
||||
var output io.Writer
|
||||
switch env {
|
||||
case config.Dev:
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006/01/02 15:04:05",
|
||||
}
|
||||
case config.Prod:
|
||||
output = os.Stderr
|
||||
}
|
||||
|
||||
logger := zerolog.New(output).With().Timestamp().Stack().Logger()
|
||||
zerolog.DefaultContextLogger = &logger
|
||||
log.Logger = logger
|
||||
|
||||
stdlog.SetFlags(0)
|
||||
stdlog.SetOutput(logger)
|
||||
return &logger
|
||||
}
|
||||
126
logging/middleware.go
Normal file
126
logging/middleware.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
879
main.go
879
main.go
@@ -2,789 +2,122 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/connect4"
|
||||
"github.com/ryanhamamura/games/db"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/logging"
|
||||
appnats "github.com/ryanhamamura/games/nats"
|
||||
"github.com/ryanhamamura/games/router"
|
||||
"github.com/ryanhamamura/games/sessions"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
"github.com/ryanhamamura/games/version"
|
||||
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
var (
|
||||
store = game.NewGameStore()
|
||||
snakeStore = snake.NewSnakeStore()
|
||||
queries *gen.Queries
|
||||
chatPersister *db.ChatPersister
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assets embed.FS
|
||||
|
||||
func DaisyUIPlugin(v *via.V) {
|
||||
css, _ := fs.ReadFile(assets, "assets/css/output.css")
|
||||
sum := md5.Sum(css)
|
||||
version := hex.EncodeToString(sum[:4])
|
||||
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version)))
|
||||
}
|
||||
|
||||
func port() string {
|
||||
if p := os.Getenv("PORT"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "7331"
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
log.Fatal(err)
|
||||
cfg := config.Global
|
||||
logging.SetupLogger(cfg.Environment, cfg.LogLevel)
|
||||
|
||||
if err := run(ctx); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal().Err(err).Msg("server error")
|
||||
}
|
||||
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 != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
LogLevel: via.LogLevelDebug,
|
||||
DocumentTitle: "Game Lobby",
|
||||
ServerAddress: ":" + port(),
|
||||
SessionManager: sessionManager,
|
||||
Plugins: []via.Plugin{DaisyUIPlugin},
|
||||
ContextSuspendAfter: 5 * time.Minute,
|
||||
ContextTTL: 30 * time.Minute,
|
||||
})
|
||||
|
||||
subFS, _ := fs.Sub(assets, "assets")
|
||||
v.StaticFS("/assets/", subFS)
|
||||
|
||||
store.SetPubSub(v.PubSub())
|
||||
snakeStore.SetPubSub(v.PubSub())
|
||||
|
||||
// Home page - tabbed lobby
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := c.Session().GetString("user_id")
|
||||
username := c.Session().GetString("username")
|
||||
isLoggedIn := userID != ""
|
||||
|
||||
var userGames []ui.GameListItem
|
||||
if isLoggedIn {
|
||||
ctx := context.Background()
|
||||
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true})
|
||||
if err == nil {
|
||||
for _, g := range games {
|
||||
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
|
||||
userGames = append(userGames, ui.GameListItem{
|
||||
ID: g.ID,
|
||||
Status: int(g.Status),
|
||||
OpponentName: g.OpponentNickname.String,
|
||||
IsMyTurn: isMyTurn,
|
||||
LastPlayed: g.UpdatedAt.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
nickname = c.Signal(username)
|
||||
}
|
||||
activeTab := c.Signal("connect4")
|
||||
|
||||
logout := c.Action(func() {
|
||||
c.Session().Clear()
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
createGame := c.Action(func() {
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
gi := store.Create()
|
||||
c.Redirectf("/game/%s", gi.ID())
|
||||
})
|
||||
|
||||
deleteGame := func(id string) h.H {
|
||||
return c.Action(func() {
|
||||
for _, g := range userGames {
|
||||
if g.ID == id {
|
||||
store.Delete(id)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.Redirect("/")
|
||||
}).OnClick()
|
||||
}
|
||||
|
||||
tabClickConnect4 := c.Action(func() {
|
||||
activeTab.SetValue("connect4")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
tabClickSnake := c.Action(func() {
|
||||
activeTab.SetValue("snake")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
snakeNickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
snakeNickname = c.Signal(username)
|
||||
}
|
||||
|
||||
// Speed selection signal (index into SpeedPresets, default to Normal which is index 1)
|
||||
selectedSpeedIndex := c.Signal(1)
|
||||
|
||||
// Speed selector actions
|
||||
var speedSelectClicks []h.H
|
||||
for i := range snake.SpeedPresets {
|
||||
idx := i
|
||||
speedSelectClicks = append(speedSelectClicks, c.Action(func() {
|
||||
selectedSpeedIndex.SetValue(idx)
|
||||
c.Sync()
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
// Snake create game actions — one per preset for solo and multiplayer
|
||||
var snakeSoloClicks []h.H
|
||||
var snakeMultiClicks []h.H
|
||||
for _, preset := range snake.GridPresets {
|
||||
w, ht := preset.Width, preset.Height
|
||||
snakeSoloClicks = append(snakeSoloClicks, c.Action(func() {
|
||||
name := snakeNickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
speedIdx := selectedSpeedIndex.Int()
|
||||
speed := snake.DefaultSpeed
|
||||
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
|
||||
speed = snake.SpeedPresets[speedIdx].Speed
|
||||
}
|
||||
si := snakeStore.Create(w, ht, snake.ModeSinglePlayer, speed)
|
||||
c.Redirectf("/snake/%s", si.ID())
|
||||
}).OnClick())
|
||||
snakeMultiClicks = append(snakeMultiClicks, c.Action(func() {
|
||||
name := snakeNickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
speedIdx := selectedSpeedIndex.Int()
|
||||
speed := snake.DefaultSpeed
|
||||
if speedIdx >= 0 && speedIdx < len(snake.SpeedPresets) {
|
||||
speed = snake.SpeedPresets[speedIdx].Speed
|
||||
}
|
||||
si := snakeStore.Create(w, ht, snake.ModeMultiplayer, speed)
|
||||
c.Redirectf("/snake/%s", si.ID())
|
||||
}).OnClick())
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LobbyView(ui.LobbyProps{
|
||||
NicknameBind: nickname.Bind(),
|
||||
CreateGameKeyDown: createGame.OnKeyDown("Enter"),
|
||||
CreateGameClick: createGame.OnClick(),
|
||||
IsLoggedIn: isLoggedIn,
|
||||
Username: username,
|
||||
LogoutClick: logout.OnClick(),
|
||||
UserGames: userGames,
|
||||
DeleteGameClick: deleteGame,
|
||||
ActiveTab: activeTab.String(),
|
||||
TabClickConnect4: tabClickConnect4.OnClick(),
|
||||
TabClickSnake: tabClickSnake.OnClick(),
|
||||
SnakeNicknameBind: snakeNickname.Bind(),
|
||||
SnakeSoloClicks: snakeSoloClicks,
|
||||
SnakeMultiClicks: snakeMultiClicks,
|
||||
ActiveSnakeGames: snakeStore.ActiveGames(),
|
||||
SelectedSpeedIndex: selectedSpeedIndex.Int(),
|
||||
SpeedSelectClicks: speedSelectClicks,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Login page
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
login := c.Action(func() {
|
||||
ctx := context.Background()
|
||||
user, err := queries.GetUserByUsername(ctx, username.String())
|
||||
if err == sql.ErrNoRows {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(password.String(), user.PasswordHash) {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
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.LoginView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
login.OnKeyDown("Enter"),
|
||||
login.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Register page
|
||||
v.Page("/register", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
confirm := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
register := c.Action(func() {
|
||||
if err := auth.ValidateUsername(username.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(password.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if password.String() != confirm.String() {
|
||||
errorMsg.SetValue("Passwords do not match")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password.String())
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
id := uuid.New().String()
|
||||
user, err := queries.CreateUser(ctx, gen.CreateUserParams{
|
||||
ID: id,
|
||||
Username: username.String(),
|
||||
PasswordHash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
errorMsg.SetValue("Username already taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().RenewToken()
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
|
||||
returnURL := c.Session().GetString("return_url")
|
||||
if returnURL != "" {
|
||||
c.Session().Set("return_url", "")
|
||||
c.Redirect(returnURL)
|
||||
} else {
|
||||
c.Redirect("/")
|
||||
}
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.RegisterView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
confirm.Bind(),
|
||||
register.OnKeyDown("Enter"),
|
||||
register.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Connect 4 game page
|
||||
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
colSignal := c.Signal(0)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
chatMsg := c.Signal("")
|
||||
chatMessages, _ := chatPersister.LoadChatMessages(gameID)
|
||||
var chatMu sync.Mutex
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/game/"+gameID)
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
goToRegister := c.Action(func() {
|
||||
c.Session().Set("return_url", "/game/"+gameID)
|
||||
c.Redirect("/register")
|
||||
})
|
||||
|
||||
continueAsGuest := c.Action(func() {
|
||||
showGuestPrompt.SetValue(true)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
var gi *game.GameInstance
|
||||
var gameExists bool
|
||||
|
||||
if gameID != "" {
|
||||
gi, gameExists = store.Get(gameID)
|
||||
}
|
||||
|
||||
playerID := game.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
playerID = game.PlayerID(game.GenerateID(8))
|
||||
c.Session().Set("player_id", string(playerID))
|
||||
}
|
||||
|
||||
if sessionUserID != "" {
|
||||
playerID = game.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
}
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
if gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
})
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
dropPiece := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
}
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
if myColor == 0 {
|
||||
return
|
||||
}
|
||||
col := colSignal.Int()
|
||||
gi.DropPiece(col, myColor)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
createRematch := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
}
|
||||
newGI := gi.CreateRematch(store)
|
||||
if newGI != nil {
|
||||
c.Redirectf("/game/%s", newGI.ID())
|
||||
}
|
||||
})
|
||||
|
||||
sendChat := c.Action(func() {
|
||||
msg := chatMsg.String()
|
||||
if msg == "" || gi == nil {
|
||||
return
|
||||
}
|
||||
color := gi.GetPlayerColor(playerID)
|
||||
if color == 0 {
|
||||
return
|
||||
}
|
||||
g := gi.GetGame()
|
||||
nick := ""
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.ID == playerID {
|
||||
nick = p.Nickname
|
||||
break
|
||||
}
|
||||
}
|
||||
cm := ui.C4ChatMessage{
|
||||
Nickname: nick,
|
||||
Color: color,
|
||||
Message: msg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
chatPersister.SaveChatMessage(gameID, cm)
|
||||
data, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Publish("game.chat."+gameID, data)
|
||||
chatMsg.SetValue("")
|
||||
})
|
||||
|
||||
if gameExists {
|
||||
c.Subscribe("game."+gameID, func(data []byte) { c.Sync() })
|
||||
|
||||
c.Subscribe("game.chat."+gameID, func(data []byte) {
|
||||
var cm ui.C4ChatMessage
|
||||
if err := json.Unmarshal(data, &cm); err != nil {
|
||||
return
|
||||
}
|
||||
chatMu.Lock()
|
||||
chatMessages = append(chatMessages, cm)
|
||||
if len(chatMessages) > 50 {
|
||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
||||
}
|
||||
chatMu.Unlock()
|
||||
c.Sync()
|
||||
})
|
||||
}
|
||||
|
||||
if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
})
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
if myColor == 0 {
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
continueAsGuest.OnClick(),
|
||||
goToRegister.OnClick(),
|
||||
)
|
||||
}
|
||||
return ui.NicknamePrompt(
|
||||
nickname.Bind(),
|
||||
setNickname.OnKeyDown("Enter"),
|
||||
setNickname.OnClick(),
|
||||
)
|
||||
}
|
||||
|
||||
g := gi.GetGame()
|
||||
|
||||
columnClick := func(col int) h.H {
|
||||
return dropPiece.OnClick(via.WithSignalInt(colSignal, col))
|
||||
}
|
||||
|
||||
chatMu.Lock()
|
||||
msgs := make([]ui.C4ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
chat := ui.C4Chat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
|
||||
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
ui.BackToLobby(),
|
||||
ui.StealthTitle("text-3xl font-bold"),
|
||||
ui.PlayerInfo(g, myColor),
|
||||
ui.StatusBanner(g, myColor, createRematch.OnClick()),
|
||||
h.Div(h.Class("c4-game-area"), ui.BoardComponent(g, columnClick, myColor), chat),
|
||||
)
|
||||
|
||||
if g.Status == game.StatusWaitingForPlayer {
|
||||
content = append(content, ui.InviteLink(g.ID))
|
||||
}
|
||||
|
||||
mainAttrs := []h.H{h.Class("flex flex-col items-center gap-4 p-4")}
|
||||
mainAttrs = append(mainAttrs, content...)
|
||||
return h.Main(mainAttrs...)
|
||||
})
|
||||
})
|
||||
|
||||
// Snake game page
|
||||
v.Page("/snake/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
showGuestPrompt := c.Signal(false)
|
||||
|
||||
goToLogin := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
goToRegister := c.Action(func() {
|
||||
c.Session().Set("return_url", "/snake/"+gameID)
|
||||
c.Redirect("/register")
|
||||
})
|
||||
|
||||
continueAsGuest := c.Action(func() {
|
||||
showGuestPrompt.SetValue(true)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
var si *snake.SnakeGameInstance
|
||||
var gameExists bool
|
||||
|
||||
if gameID != "" {
|
||||
si, gameExists = snakeStore.Get(gameID)
|
||||
}
|
||||
|
||||
playerID := snake.PlayerID(c.Session().GetString("player_id"))
|
||||
if playerID == "" {
|
||||
pid := game.GenerateID(8)
|
||||
playerID = snake.PlayerID(pid)
|
||||
c.Session().Set("player_id", pid)
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
playerID = snake.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
name := nickname.String()
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
c.Session().Set("nickname", name)
|
||||
|
||||
if si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
// Direction input: single action with a direction signal
|
||||
dirSignal := c.Signal(-1)
|
||||
handleDir := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
return
|
||||
}
|
||||
dir := snake.Direction(dirSignal.Int())
|
||||
si.SetDirection(slot, dir)
|
||||
})
|
||||
|
||||
createRematch := c.Action(func() {
|
||||
if si == nil {
|
||||
return
|
||||
}
|
||||
newSI := si.CreateRematch()
|
||||
if newSI != nil {
|
||||
c.Redirectf("/snake/%s", newSI.ID())
|
||||
}
|
||||
})
|
||||
|
||||
chatMsg := c.Signal("")
|
||||
var chatMessages []ui.ChatMessage
|
||||
var chatMu sync.Mutex
|
||||
|
||||
sendChat := c.Action(func() {
|
||||
msg := chatMsg.String()
|
||||
if msg == "" || si == nil {
|
||||
return
|
||||
}
|
||||
slot := si.GetPlayerSlot(playerID)
|
||||
if slot < 0 {
|
||||
return
|
||||
}
|
||||
cm := ui.ChatMessage{
|
||||
Nickname: si.GetGame().Players[slot].Nickname,
|
||||
Slot: slot,
|
||||
Message: msg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Publish("snake.chat."+gameID, data)
|
||||
chatMsg.SetValue("")
|
||||
})
|
||||
|
||||
if gameExists {
|
||||
c.Subscribe("snake."+gameID, func(data []byte) { c.Sync() })
|
||||
|
||||
if si.GetGame().Mode == snake.ModeMultiplayer {
|
||||
c.Subscribe("snake.chat."+gameID, func(data []byte) {
|
||||
var cm ui.ChatMessage
|
||||
if err := json.Unmarshal(data, &cm); err != nil {
|
||||
return
|
||||
}
|
||||
chatMu.Lock()
|
||||
chatMessages = append(chatMessages, cm)
|
||||
if len(chatMessages) > 50 {
|
||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
||||
}
|
||||
chatMu.Unlock()
|
||||
c.Sync()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-join if nickname exists
|
||||
if gameExists && sessionNickname != "" && si.GetPlayerSlot(playerID) < 0 {
|
||||
player := &snake.Player{
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
si.Join(player)
|
||||
}
|
||||
|
||||
c.View(func() h.H {
|
||||
if !gameExists {
|
||||
c.Redirect("/")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
if mySlot < 0 {
|
||||
if sessionUserID == "" && !showGuestPrompt.Bool() {
|
||||
return ui.GameJoinPrompt(
|
||||
goToLogin.OnClick(),
|
||||
continueAsGuest.OnClick(),
|
||||
goToRegister.OnClick(),
|
||||
)
|
||||
}
|
||||
return ui.NicknamePrompt(
|
||||
nickname.Bind(),
|
||||
setNickname.OnKeyDown("Enter"),
|
||||
setNickname.OnClick(),
|
||||
)
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
|
||||
var content []h.H
|
||||
content = append(content,
|
||||
ui.BackToLobby(),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("~~~~")),
|
||||
ui.SnakePlayerList(sg, mySlot),
|
||||
ui.SnakeStatusBanner(sg, mySlot, createRematch.OnClick()),
|
||||
)
|
||||
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
board := ui.SnakeBoard(sg)
|
||||
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
chatMu.Lock()
|
||||
msgs := make([]ui.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
chat := ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter"))
|
||||
content = append(content, h.Div(h.Class("snake-game-area"), board, chat))
|
||||
} else {
|
||||
content = append(content, board)
|
||||
}
|
||||
} else if sg.Mode == snake.ModeMultiplayer {
|
||||
// Show chat even before game starts (waiting/countdown)
|
||||
chatMu.Lock()
|
||||
msgs := make([]ui.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
content = append(content, ui.SnakeChat(msgs, chatMsg.Bind(), sendChat.OnClick(), sendChat.OnKeyDown("Enter")))
|
||||
}
|
||||
|
||||
// Only show invite link for multiplayer games
|
||||
if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) {
|
||||
content = append(content, ui.SnakeInviteLink(sg.ID))
|
||||
}
|
||||
|
||||
wrapperAttrs := []h.H{
|
||||
h.Class("snake-wrapper flex flex-col items-center gap-4 p-4"),
|
||||
via.OnKeyDownMap(
|
||||
via.KeyBind("w", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("a", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("s", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("d", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowUp", handleDir, via.WithSignalInt(dirSignal, int(snake.DirUp)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowLeft", handleDir, via.WithSignalInt(dirSignal, int(snake.DirLeft)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowDown", handleDir, via.WithSignalInt(dirSignal, int(snake.DirDown)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
via.KeyBind("ArrowRight", handleDir, via.WithSignalInt(dirSignal, int(snake.DirRight)), via.WithPreventDefault(), via.WithThrottle(100*time.Millisecond)),
|
||||
),
|
||||
}
|
||||
|
||||
wrapperAttrs = append(wrapperAttrs, content...)
|
||||
return h.Main(wrapperAttrs...)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("initializing database: %w", err)
|
||||
}
|
||||
defer cleanupDB()
|
||||
|
||||
queries := repository.New(db.DB)
|
||||
|
||||
// Sessions
|
||||
sessionManager, cleanupSessions := sessions.SetupSessionManager(db.DB)
|
||||
defer cleanupSessions()
|
||||
|
||||
// NATS
|
||||
nc, cleanupNATS, err := appnats.SetupNATS(egctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting up NATS: %w", err)
|
||||
}
|
||||
defer cleanupNATS()
|
||||
|
||||
// Game stores
|
||||
store := connect4.NewStore(queries)
|
||||
store.SetNotifyFunc(func(gameID string) {
|
||||
nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
|
||||
})
|
||||
|
||||
snakeStore := snake.NewSnakeStore(queries)
|
||||
snakeStore.SetNotifyFunc(func(gameID string) {
|
||||
nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification
|
||||
})
|
||||
|
||||
// Router
|
||||
logger := log.Logger
|
||||
r := chi.NewMux()
|
||||
r.Use(
|
||||
logging.RequestLogger(&logger, cfg.Environment),
|
||||
middleware.Recoverer,
|
||||
sessionManager.LoadAndSave,
|
||||
)
|
||||
|
||||
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets)
|
||||
|
||||
// HTTP server
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return egctx
|
||||
},
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("server error: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
<-egctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
slog.Debug("shutting down server...")
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
69
nats/nats.go
Normal file
69
nats/nats.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package nats sets up an embedded NATS server for real-time pub/sub
|
||||
// messaging between game clients.
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/delaneyj/toolbelt"
|
||||
"github.com/delaneyj/toolbelt/embeddednats"
|
||||
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func SetupNATS(ctx context.Context) (*nats.Conn, func(), error) {
|
||||
natsPort, err := getFreeNatsPort()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("obtaining NATS port: %w", err)
|
||||
}
|
||||
|
||||
ns, err := embeddednats.New(ctx, embeddednats.WithNATSServerOptions(&natsserver.Options{
|
||||
NoSigs: true,
|
||||
Port: natsPort,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating embedded nats server: %w", err)
|
||||
}
|
||||
|
||||
ns.WaitForServer()
|
||||
slog.Info("NATS started", "port", natsPort)
|
||||
|
||||
nc, err := ns.Client()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating nats client: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
nc.Close()
|
||||
ns.Close() //nolint:errcheck
|
||||
}
|
||||
|
||||
return nc, cleanup, nil
|
||||
}
|
||||
|
||||
func getFreeNatsPort() (int, error) {
|
||||
if p, ok := os.LookupEnv("NATS_PORT"); ok {
|
||||
natsPort, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing NATS_PORT: %w", err)
|
||||
}
|
||||
if isPortFree(natsPort) {
|
||||
return natsPort, nil
|
||||
}
|
||||
}
|
||||
return toolbelt.FreePort()
|
||||
}
|
||||
|
||||
func isPortFree(port int) bool {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ln.Close() //nolint:errcheck // checking port availability
|
||||
return true
|
||||
}
|
||||
18
player/player.go
Normal file
18
player/player.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Package player provides shared identity types used across game packages.
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// ID uniquely identifies a player within a session. For authenticated users
|
||||
// this is their user UUID; for guests it's a random hex string.
|
||||
type ID string
|
||||
|
||||
// GenerateID returns a random hex string of 2*size characters.
|
||||
func GenerateID(size int) string {
|
||||
b := make([]byte, size)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
72
router/router.go
Normal file
72
router/router.go
Normal 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
67
sessions/sessions.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Package sessions configures the SCS session manager and provides
|
||||
// helpers for resolving player identity from the session.
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/games/player"
|
||||
|
||||
"github.com/alexedwards/scs/sqlite3store"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
// Session key names.
|
||||
const (
|
||||
KeyPlayerID = "player_id"
|
||||
KeyUserID = "user_id"
|
||||
KeyNickname = "nickname"
|
||||
)
|
||||
|
||||
// SetupSessionManager creates a configured session manager backed by SQLite.
|
||||
// Returns the manager and a cleanup function the caller should defer.
|
||||
func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
|
||||
store := sqlite3store.New(db)
|
||||
cleanup := func() { store.StopCleanup() }
|
||||
|
||||
sessionManager := scs.New()
|
||||
sessionManager.Store = store
|
||||
sessionManager.Lifetime = 30 * 24 * time.Hour
|
||||
sessionManager.Cookie.Name = "games_session"
|
||||
sessionManager.Cookie.Path = "/"
|
||||
sessionManager.Cookie.HttpOnly = true
|
||||
sessionManager.Cookie.Secure = 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)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package snake implements snake game logic, state management, and persistence.
|
||||
package snake
|
||||
|
||||
import "math/rand"
|
||||
|
||||
@@ -61,17 +61,15 @@ func (si *SnakeGameInstance) countdownPhase() {
|
||||
si.initGame()
|
||||
si.game.Status = StatusInProgress
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
return
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
}
|
||||
// No DB save during countdown ticks — state is transient
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
}
|
||||
@@ -98,6 +96,7 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
defer ticker.Stop()
|
||||
|
||||
lastInput := time.Now()
|
||||
lastSave := time.Now()
|
||||
var moveAccum time.Duration
|
||||
|
||||
for {
|
||||
@@ -123,8 +122,8 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
// Inactivity timeout
|
||||
if time.Since(lastInput) > inactivityLimit {
|
||||
si.game.Status = StatusFinished
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
si.notify()
|
||||
@@ -175,13 +174,10 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
alive := AliveCount(state)
|
||||
gameOver := false
|
||||
if si.game.Mode == ModeSinglePlayer {
|
||||
// Single player ends when the player dies (alive == 0)
|
||||
if alive == 0 {
|
||||
gameOver = true
|
||||
// No winner in single player - just final score
|
||||
}
|
||||
} else {
|
||||
// Multiplayer ends when 1 or fewer alive
|
||||
if alive <= 1 {
|
||||
gameOver = true
|
||||
winnerIdx := LastAlive(state)
|
||||
@@ -195,8 +191,10 @@ func (si *SnakeGameInstance) gamePhase() {
|
||||
si.game.Status = StatusFinished
|
||||
}
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
// Throttle DB saves: persist on game over or every 2 seconds
|
||||
if si.queries != nil && (gameOver || time.Since(lastSave) >= 2*time.Second) {
|
||||
si.save() //nolint:errcheck
|
||||
lastSave = time.Now()
|
||||
}
|
||||
|
||||
si.gameMu.Unlock()
|
||||
|
||||
141
snake/persist.go
Normal file
141
snake/persist.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (si *SnakeGameInstance) save() error {
|
||||
err := saveSnakeGame(si.queries, si.game)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("game_id", si.game.ID).Msg("failed to save snake game")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) savePlayer(player *Player) error {
|
||||
err := saveSnakePlayer(si.queries, si.game.ID, player)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("game_id", si.game.ID).Msg("failed to save snake player")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// saveSnakeGame persists the snake game state via upsert.
|
||||
func saveSnakeGame(queries *repository.Queries, sg *SnakeGame) error {
|
||||
boardJSON := "{}"
|
||||
var gridWidth, gridHeight *int64
|
||||
if sg.State != nil {
|
||||
boardJSON = sg.State.ToJSON()
|
||||
w, h := int64(sg.State.Width), int64(sg.State.Height)
|
||||
gridWidth, gridHeight = &w, &h
|
||||
}
|
||||
|
||||
var winnerUserID *string
|
||||
if sg.Winner != nil && sg.Winner.UserID != nil {
|
||||
winnerUserID = sg.Winner.UserID
|
||||
}
|
||||
|
||||
return queries.UpsertSnakeGame(context.Background(), repository.UpsertSnakeGameParams{
|
||||
ID: sg.ID,
|
||||
Board: boardJSON,
|
||||
Status: int64(sg.Status),
|
||||
GridWidth: gridWidth,
|
||||
GridHeight: gridHeight,
|
||||
GameMode: int64(sg.Mode),
|
||||
SnakeSpeed: int64(sg.Speed),
|
||||
WinnerUserID: winnerUserID,
|
||||
RematchGameID: sg.RematchGameID,
|
||||
Score: int64(sg.Score),
|
||||
})
|
||||
}
|
||||
|
||||
func saveSnakePlayer(queries *repository.Queries, gameID string, player *Player) error {
|
||||
var userID, guestPlayerID *string
|
||||
if player.UserID != nil {
|
||||
userID = player.UserID
|
||||
} else {
|
||||
id := string(player.ID)
|
||||
guestPlayerID = &id
|
||||
}
|
||||
|
||||
return queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
GuestPlayerID: guestPlayerID,
|
||||
Nickname: player.Nickname,
|
||||
Color: int64(player.Slot + 1),
|
||||
Slot: int64(player.Slot),
|
||||
})
|
||||
}
|
||||
|
||||
func loadSnakeGame(queries *repository.Queries, id string) (*SnakeGame, error) {
|
||||
row, err := queries.GetSnakeGame(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snakeGameFromRow(row)
|
||||
}
|
||||
|
||||
func loadSnakePlayers(queries *repository.Queries, id string) ([]*Player, error) {
|
||||
rows, err := queries.GetSnakePlayers(context.Background(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snakePlayersFromRows(rows), nil
|
||||
}
|
||||
|
||||
// Domain ↔ DB mapping helpers.
|
||||
|
||||
func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
|
||||
state, err := GameStateFromJSON(row.Board)
|
||||
if err != nil {
|
||||
state = &GameState{}
|
||||
}
|
||||
if row.GridWidth != nil {
|
||||
state.Width = int(*row.GridWidth)
|
||||
}
|
||||
if row.GridHeight != nil {
|
||||
state.Height = int(*row.GridHeight)
|
||||
}
|
||||
|
||||
sg := &SnakeGame{
|
||||
ID: row.ID,
|
||||
State: state,
|
||||
Players: make([]*Player, 8),
|
||||
Status: Status(row.Status),
|
||||
Mode: GameMode(row.GameMode),
|
||||
Score: int(row.Score),
|
||||
Speed: int(row.SnakeSpeed),
|
||||
}
|
||||
|
||||
if row.RematchGameID != nil {
|
||||
sg.RematchGameID = row.RematchGameID
|
||||
}
|
||||
|
||||
return sg, nil
|
||||
}
|
||||
|
||||
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
|
||||
players := make([]*Player, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
p := &Player{
|
||||
Nickname: row.Nickname,
|
||||
Slot: int(row.Slot),
|
||||
}
|
||||
|
||||
if row.UserID != nil {
|
||||
p.UserID = row.UserID
|
||||
p.ID = player.ID(*row.UserID)
|
||||
} else if row.GuestPlayerID != nil {
|
||||
p.ID = player.ID(*row.GuestPlayerID)
|
||||
}
|
||||
|
||||
players = append(players, p)
|
||||
}
|
||||
return players
|
||||
}
|
||||
107
snake/store.go
107
snake/store.go
@@ -1,48 +1,35 @@
|
||||
package snake
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
}
|
||||
|
||||
type Persister interface {
|
||||
SaveSnakeGame(sg *SnakeGame) error
|
||||
LoadSnakeGame(id string) (*SnakeGame, error)
|
||||
SaveSnakePlayer(gameID string, player *Player) error
|
||||
LoadSnakePlayers(gameID string) ([]*Player, error)
|
||||
DeleteSnakeGame(id string) error
|
||||
}
|
||||
|
||||
type SnakeStore struct {
|
||||
games map[string]*SnakeGameInstance
|
||||
gamesMu sync.RWMutex
|
||||
persister Persister
|
||||
pubsub PubSub
|
||||
games map[string]*SnakeGameInstance
|
||||
gamesMu sync.RWMutex
|
||||
queries *repository.Queries
|
||||
notifyFunc func(gameID string)
|
||||
}
|
||||
|
||||
func NewSnakeStore() *SnakeStore {
|
||||
func NewSnakeStore(queries *repository.Queries) *SnakeStore {
|
||||
return &SnakeStore{
|
||||
games: make(map[string]*SnakeGameInstance),
|
||||
games: make(map[string]*SnakeGameInstance),
|
||||
queries: queries,
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPersister(p Persister) {
|
||||
ss.persister = p
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) SetPubSub(ps PubSub) {
|
||||
ss.pubsub = ps
|
||||
func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
|
||||
ss.notifyFunc = f
|
||||
}
|
||||
|
||||
func (ss *SnakeStore) makeNotify(gameID string) func() {
|
||||
return func() {
|
||||
if ss.pubsub != nil {
|
||||
ss.pubsub.Publish("snake."+gameID, nil)
|
||||
if ss.notifyFunc != nil {
|
||||
ss.notifyFunc(gameID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
||||
if speed <= 0 {
|
||||
speed = DefaultSpeed
|
||||
}
|
||||
id := generateID(4)
|
||||
id := player.GenerateID(4)
|
||||
sg := &SnakeGame{
|
||||
ID: id,
|
||||
State: &GameState{
|
||||
@@ -64,18 +51,18 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
||||
Speed: speed,
|
||||
}
|
||||
si := &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
store: ss,
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
queries: ss.queries,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
ss.gamesMu.Lock()
|
||||
ss.games[id] = si
|
||||
ss.gamesMu.Unlock()
|
||||
|
||||
if ss.persister != nil {
|
||||
ss.persister.SaveSnakeGame(sg)
|
||||
if ss.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
return si
|
||||
@@ -90,16 +77,16 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
||||
return si, true
|
||||
}
|
||||
|
||||
if ss.persister == nil {
|
||||
if ss.queries == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sg, err := ss.persister.LoadSnakeGame(id)
|
||||
sg, err := loadSnakeGame(ss.queries, id)
|
||||
if err != nil || sg == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
players, _ := ss.persister.LoadSnakePlayers(id)
|
||||
players, _ := loadSnakePlayers(ss.queries, id)
|
||||
if sg.Players == nil {
|
||||
sg.Players = make([]*Player, 8)
|
||||
}
|
||||
@@ -110,10 +97,10 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
|
||||
}
|
||||
|
||||
si = &SnakeGameInstance{
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
persister: ss.persister,
|
||||
store: ss,
|
||||
game: sg,
|
||||
notify: ss.makeNotify(id),
|
||||
queries: ss.queries,
|
||||
store: ss,
|
||||
}
|
||||
|
||||
ss.gamesMu.Lock()
|
||||
@@ -133,8 +120,8 @@ func (ss *SnakeStore) Delete(id string) error {
|
||||
si.Stop()
|
||||
}
|
||||
|
||||
if ss.persister != nil {
|
||||
return ss.persister.DeleteSnakeGame(id)
|
||||
if ss.queries != nil {
|
||||
return ss.queries.DeleteSnakeGame(context.Background(), id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -162,14 +149,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
||||
}
|
||||
|
||||
type SnakeGameInstance struct {
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
game *SnakeGame
|
||||
gameMu sync.RWMutex
|
||||
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
|
||||
notify func()
|
||||
persister Persister
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
notify func()
|
||||
queries *repository.Queries
|
||||
store *SnakeStore
|
||||
stopCh chan struct{}
|
||||
loopOnce sync.Once
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) ID() string {
|
||||
@@ -185,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame {
|
||||
return si.game.snapshot()
|
||||
}
|
||||
|
||||
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
|
||||
func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int {
|
||||
si.gameMu.RLock()
|
||||
defer si.gameMu.RUnlock()
|
||||
for i, p := range si.game.Players {
|
||||
@@ -218,9 +205,9 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
|
||||
player.Slot = slot
|
||||
si.game.Players[slot] = player
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakePlayer(si.game.ID, player)
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.savePlayer(player) //nolint:errcheck
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
|
||||
si.notify()
|
||||
@@ -305,17 +292,11 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
|
||||
}
|
||||
si.game.RematchGameID = &newID
|
||||
|
||||
if si.persister != nil {
|
||||
si.persister.SaveSnakeGame(si.game)
|
||||
if si.queries != nil {
|
||||
si.save() //nolint:errcheck
|
||||
}
|
||||
si.gameMu.Unlock()
|
||||
|
||||
si.notify()
|
||||
return newSI
|
||||
}
|
||||
|
||||
func generateID(size int) string {
|
||||
b := make([]byte, size)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,19 @@ package snake
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/games/player"
|
||||
)
|
||||
|
||||
// SubjectPrefix is the NATS subject namespace for snake games.
|
||||
const SubjectPrefix = "snake"
|
||||
|
||||
// GameSubject returns the NATS subject for game state updates.
|
||||
func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID }
|
||||
|
||||
// ChatSubject returns the NATS subject for chat messages.
|
||||
func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID }
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
@@ -78,10 +89,8 @@ const (
|
||||
StatusFinished
|
||||
)
|
||||
|
||||
type PlayerID string
|
||||
|
||||
type Player struct {
|
||||
ID PlayerID
|
||||
ID player.ID
|
||||
UserID *string
|
||||
Nickname string
|
||||
Slot int // 0-7
|
||||
@@ -100,7 +109,7 @@ type SnakeGame struct {
|
||||
Speed int // cells per second
|
||||
}
|
||||
|
||||
// Speed presets
|
||||
// SpeedPreset defines a named speed option for the snake game.
|
||||
type SpeedPreset struct {
|
||||
Name string
|
||||
Speed int
|
||||
@@ -129,7 +138,7 @@ func (sg *SnakeGame) PlayerCount() int {
|
||||
return count
|
||||
}
|
||||
|
||||
// Grid presets
|
||||
// GridPreset defines a named grid size option for the snake game.
|
||||
type GridPreset struct {
|
||||
Name string
|
||||
Width int
|
||||
@@ -163,7 +172,7 @@ func (sg *SnakeGame) snapshot() *SnakeGame {
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Snake colors (hex values for CSS)
|
||||
// SnakeColors are hex color values for CSS, indexed by player slot.
|
||||
var SnakeColors = []string{
|
||||
"#00b894", // 1: Green
|
||||
"#e17055", // 2: Orange
|
||||
|
||||
47
testutil/db.go
Normal file
47
testutil/db.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Package testutil provides composable test helpers for spinning up
|
||||
// real infrastructure (in-memory SQLite, session managers) in
|
||||
// integration tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/games/db"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// NewTestDB opens an in-memory SQLite database with the same pragmas as
|
||||
// production, runs all goose migrations, and returns the raw connection
|
||||
// alongside the sqlc Queries handle. The database is closed automatically
|
||||
// when the test finishes.
|
||||
func NewTestDB(t *testing.T) (*sql.DB, *repository.Queries) {
|
||||
t.Helper()
|
||||
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
|
||||
database, err := goose.OpenDBWithDriver("sqlite", ":memory:"+pragmas)
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() }) //nolint:errcheck // test cleanup
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
t.Fatalf("ping test database: %v", err)
|
||||
}
|
||||
|
||||
sub, err := fs.Sub(db.MigrationFS, "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrations sub fs: %v", err)
|
||||
}
|
||||
goose.SetBaseFS(sub)
|
||||
|
||||
if err := goose.Up(database, "."); err != nil {
|
||||
t.Fatalf("run migrations: %v", err)
|
||||
}
|
||||
|
||||
return database, repository.New(database)
|
||||
}
|
||||
31
testutil/sessions.go
Normal file
31
testutil/sessions.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/sqlite3store"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
// NewTestSessionManager creates an SCS session manager backed by the
|
||||
// provided SQLite database. The background cleanup goroutine is stopped
|
||||
// automatically when the test finishes.
|
||||
func NewTestSessionManager(t *testing.T, db *sql.DB) *scs.SessionManager {
|
||||
t.Helper()
|
||||
|
||||
store := sqlite3store.New(db)
|
||||
t.Cleanup(func() { store.StopCleanup() })
|
||||
|
||||
sm := scs.New()
|
||||
sm.Store = store
|
||||
sm.Lifetime = 30 * 24 * time.Hour
|
||||
sm.Cookie.Path = "/"
|
||||
sm.Cookie.HttpOnly = true
|
||||
sm.Cookie.Secure = false
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
|
||||
return sm
|
||||
}
|
||||
130
ui/auth.go
130
ui/auth.go
@@ -1,130 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func LoginView(usernameBind, passwordBind, loginKeyDown, loginClick h.H, errorMsg string) h.H {
|
||||
var errorEl h.H
|
||||
if errorMsg != "" {
|
||||
errorEl = h.P(h.Class("alert alert-error mb-4"), h.Text(errorMsg))
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Login")),
|
||||
h.P(h.Class("mb-4"), h.Text("Sign in to your account")),
|
||||
errorEl,
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Username"), h.Attr("for", "username")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("username"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your username"),
|
||||
usernameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
),
|
||||
h.Label(h.Class("label"), h.Text("Password"), h.Attr("for", "password")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("password"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Enter your password"),
|
||||
passwordBind,
|
||||
h.Attr("required"),
|
||||
loginKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Login"),
|
||||
loginClick,
|
||||
),
|
||||
),
|
||||
h.P(
|
||||
h.Text("Don't have an account? "),
|
||||
h.A(h.Class("link"), h.Href("/register"), h.Text("Register")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func RegisterView(usernameBind, passwordBind, confirmBind, registerKeyDown, registerClick h.H, errorMsg string) h.H {
|
||||
var errorEl h.H
|
||||
if errorMsg != "" {
|
||||
errorEl = h.P(h.Class("alert alert-error mb-4"), h.Text(errorMsg))
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Register")),
|
||||
h.P(h.Class("mb-4"), h.Text("Create a new account")),
|
||||
errorEl,
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Username"), h.Attr("for", "username")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("username"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Choose a username"),
|
||||
usernameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
),
|
||||
h.Label(h.Class("label"), h.Text("Password"), h.Attr("for", "password")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("password"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Choose a password (min 8 chars)"),
|
||||
passwordBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
h.Label(h.Class("label"), h.Text("Confirm Password"), h.Attr("for", "confirm")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("confirm"),
|
||||
h.Type("password"),
|
||||
h.Placeholder("Confirm your password"),
|
||||
confirmBind,
|
||||
h.Attr("required"),
|
||||
registerKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Register"),
|
||||
registerClick,
|
||||
),
|
||||
),
|
||||
h.P(
|
||||
h.Text("Already have an account? "),
|
||||
h.A(h.Class("link"), h.Href("/login"), h.Text("Login")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func AuthHeader(username string, logoutClick h.H) h.H {
|
||||
return h.Div(h.Class("flex justify-center items-center gap-4 mb-4 p-2 bg-base-200 rounded-lg"),
|
||||
h.Span(h.Text("Logged in as "), h.Strong(h.Text(username))),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Class("btn btn-ghost btn-sm"),
|
||||
h.Text("Logout"),
|
||||
logoutClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func GuestBanner() h.H {
|
||||
return h.Div(h.Class("alert text-sm mb-4"),
|
||||
h.Text("Playing as guest. "),
|
||||
h.A(h.Class("link"), h.Href("/login"), h.Text("Login")),
|
||||
h.Text(" or "),
|
||||
h.A(h.Class("link"), h.Href("/register"), h.Text("Register")),
|
||||
h.Text(" to save your games."),
|
||||
)
|
||||
}
|
||||
69
ui/board.go
69
ui/board.go
@@ -1,69 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
// ColumnClickFn returns an h.H onClick attribute for a given column index
|
||||
type ColumnClickFn func(col int) h.H
|
||||
|
||||
func BoardComponent(g *game.Game, columnClick ColumnClickFn, myColor int) h.H {
|
||||
var cols []h.H
|
||||
|
||||
activeTurn := 0
|
||||
if g.Status == game.StatusInProgress {
|
||||
activeTurn = g.CurrentTurn
|
||||
}
|
||||
|
||||
for col := 0; col < 7; col++ {
|
||||
var cells []h.H
|
||||
for row := 0; row < 6; row++ {
|
||||
cellColor := g.Board[row][col]
|
||||
isWinning := g.IsWinningCell(row, col)
|
||||
isActiveTurn := cellColor != 0 && cellColor == activeTurn
|
||||
cells = append(cells, Cell(cellColor, isWinning, isActiveTurn))
|
||||
}
|
||||
|
||||
// Column is clickable only if it's player's turn and game is in progress
|
||||
canClick := g.Status == game.StatusInProgress && g.CurrentTurn == myColor
|
||||
cols = append(cols, Column(col, cells, columnClick, canClick))
|
||||
}
|
||||
|
||||
boardAttrs := []h.H{h.Class("board")}
|
||||
boardAttrs = append(boardAttrs, cols...)
|
||||
return h.Div(boardAttrs...)
|
||||
}
|
||||
|
||||
func Column(colIdx int, cells []h.H, columnClick ColumnClickFn, canClick bool) h.H {
|
||||
class := "column"
|
||||
if canClick {
|
||||
class += " clickable"
|
||||
}
|
||||
|
||||
attrs := []h.H{h.Class(class)}
|
||||
|
||||
if canClick && columnClick != nil {
|
||||
attrs = append(attrs, columnClick(colIdx))
|
||||
}
|
||||
|
||||
attrs = append(attrs, cells...)
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
|
||||
func Cell(color int, isWinning, isActiveTurn bool) h.H {
|
||||
class := "cell"
|
||||
switch color {
|
||||
case 1:
|
||||
class += " red"
|
||||
case 2:
|
||||
class += " yellow"
|
||||
}
|
||||
if isWinning {
|
||||
class += " winning"
|
||||
}
|
||||
if isActiveTurn {
|
||||
class += " active-turn"
|
||||
}
|
||||
return h.Div(h.Class(class))
|
||||
}
|
||||
64
ui/c4chat.go
64
ui/c4chat.go
@@ -1,64 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type C4ChatMessage struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Color int `json:"color"` // 1=Red, 2=Yellow
|
||||
Message string `json:"message"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
var c4ChatColors = map[int]string{
|
||||
1: "#4a2a3a",
|
||||
2: "#2a4545",
|
||||
}
|
||||
|
||||
func C4Chat(messages []C4ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H {
|
||||
var msgEls []h.H
|
||||
for _, m := range messages {
|
||||
color := "#666"
|
||||
if c, ok := c4ChatColors[m.Color]; ok {
|
||||
color = c
|
||||
}
|
||||
msgEls = append(msgEls, h.Div(h.Class("c4-chat-msg"),
|
||||
h.Span(
|
||||
h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)),
|
||||
h.Text(m.Nickname+": "),
|
||||
),
|
||||
h.Span(h.Text(m.Message)),
|
||||
))
|
||||
}
|
||||
|
||||
autoScroll := h.Script(h.Text(`
|
||||
(function(){
|
||||
var el = document.querySelector('.c4-chat-history');
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||
.observe(el, {childList:true, subtree:true});
|
||||
})();
|
||||
`))
|
||||
|
||||
historyAttrs := []h.H{h.Class("c4-chat-history")}
|
||||
historyAttrs = append(historyAttrs, msgEls...)
|
||||
historyAttrs = append(historyAttrs, autoScroll)
|
||||
|
||||
return h.Div(h.Class("c4-chat"),
|
||||
h.Div(historyAttrs...),
|
||||
h.Div(h.Class("c4-chat-input"), h.DataIgnoreMorph(),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Attr("placeholder", "Chat..."),
|
||||
h.Attr("autocomplete", "off"),
|
||||
msgBind,
|
||||
sendKeyDown,
|
||||
),
|
||||
h.Button(h.Type("button"), h.Text("Send"), sendClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
110
ui/gamelist.go
110
ui/gamelist.go
@@ -1,110 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type GameListItem struct {
|
||||
ID string
|
||||
Status int
|
||||
OpponentName string
|
||||
IsMyTurn bool
|
||||
LastPlayed time.Time
|
||||
}
|
||||
|
||||
func GameList(games []GameListItem, deleteClick func(id string) h.H) h.H {
|
||||
if len(games) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []h.H
|
||||
for _, g := range games {
|
||||
items = append(items, gameListEntry(g, deleteClick))
|
||||
}
|
||||
|
||||
listItems := []h.H{h.Class("flex flex-col gap-2")}
|
||||
listItems = append(listItems, items...)
|
||||
|
||||
return h.Div(h.Class("mt-8 text-left"),
|
||||
h.H3(h.Class("mb-4 text-center text-lg font-bold"), h.Text("Your Games")),
|
||||
h.Div(listItems...),
|
||||
)
|
||||
}
|
||||
|
||||
func gameListEntry(g GameListItem, deleteClick func(id string) h.H) h.H {
|
||||
statusText, statusClass := getStatusDisplay(g)
|
||||
|
||||
return h.Div(h.Class("flex items-center gap-2 p-2 bg-base-200 rounded-lg transition-colors hover:bg-base-300"),
|
||||
h.A(
|
||||
h.Href("/game/"+g.ID),
|
||||
h.Class("flex-1 flex justify-between items-center px-2 py-1 no-underline text-base-content"),
|
||||
h.Div(h.Class("flex flex-col gap-1"),
|
||||
h.Span(h.Class("font-bold"), h.Text(getOpponentDisplay(g))),
|
||||
h.Span(h.Class(statusClass), h.Text(statusText)),
|
||||
),
|
||||
h.Div(
|
||||
h.Span(h.Class("text-xs opacity-60"), h.Text(formatTimeAgo(g.LastPlayed))),
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Type("button"),
|
||||
h.Class("btn btn-ghost btn-sm btn-square hover:btn-error"),
|
||||
h.Text("\u00d7"),
|
||||
deleteClick(g.ID),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func getStatusDisplay(g GameListItem) (string, string) {
|
||||
switch game.GameStatus(g.Status) {
|
||||
case game.StatusWaitingForPlayer:
|
||||
return "Waiting for opponent", "text-sm opacity-60"
|
||||
case game.StatusInProgress:
|
||||
if g.IsMyTurn {
|
||||
return "Your turn!", "text-sm text-success font-bold"
|
||||
}
|
||||
return "Opponent's turn", "text-sm"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func getOpponentDisplay(g GameListItem) string {
|
||||
if g.OpponentName == "" {
|
||||
return "Waiting for opponent..."
|
||||
}
|
||||
return "vs " + g.OpponentName
|
||||
}
|
||||
|
||||
func formatTimeAgo(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
duration := time.Since(t)
|
||||
|
||||
if duration < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if duration < time.Hour {
|
||||
mins := int(duration.Minutes())
|
||||
if mins == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", mins)
|
||||
}
|
||||
if duration < 24*time.Hour {
|
||||
hours := int(duration.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
days := int(duration.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "yesterday"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
153
ui/lobby.go
153
ui/lobby.go
@@ -1,153 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type LobbyProps struct {
|
||||
NicknameBind h.H
|
||||
CreateGameKeyDown h.H
|
||||
CreateGameClick h.H
|
||||
IsLoggedIn bool
|
||||
Username string
|
||||
LogoutClick h.H
|
||||
UserGames []GameListItem
|
||||
DeleteGameClick func(id string) h.H
|
||||
ActiveTab string
|
||||
TabClickConnect4 h.H
|
||||
TabClickSnake h.H
|
||||
SnakeNicknameBind h.H
|
||||
SnakeSoloClicks []h.H
|
||||
SnakeMultiClicks []h.H
|
||||
ActiveSnakeGames []*snake.SnakeGame
|
||||
SelectedSpeedIndex int
|
||||
SpeedSelectClicks []h.H
|
||||
}
|
||||
|
||||
func BackToLobby() h.H {
|
||||
return h.A(h.Class("link text-sm opacity-70"), h.Href("/"), h.Text("← Back"))
|
||||
}
|
||||
|
||||
func StealthTitle(class string) h.H {
|
||||
return h.Span(h.Class(class),
|
||||
h.Span(h.Style("color:#4a2a3a"), h.Text("●")),
|
||||
h.Span(h.Style("color:#2a4545"), h.Text("●")),
|
||||
h.Span(h.Style("color:#4a2a3a"), h.Text("●")),
|
||||
h.Span(h.Style("color:#2a4545"), h.Text("●")),
|
||||
)
|
||||
}
|
||||
|
||||
func LobbyView(p LobbyProps) h.H {
|
||||
var authSection h.H
|
||||
if p.IsLoggedIn {
|
||||
authSection = AuthHeader(p.Username, p.LogoutClick)
|
||||
} else {
|
||||
authSection = GuestBanner()
|
||||
}
|
||||
|
||||
connect4Class := "tab"
|
||||
snakeClass := "tab"
|
||||
if p.ActiveTab == "snake" {
|
||||
snakeClass += " tab-active"
|
||||
} else {
|
||||
connect4Class += " tab-active"
|
||||
}
|
||||
|
||||
var tabContent h.H
|
||||
if p.ActiveTab == "snake" {
|
||||
tabContent = SnakeLobbyTab(p.SnakeNicknameBind, p.SnakeSoloClicks, p.SnakeMultiClicks, p.ActiveSnakeGames, p.SelectedSpeedIndex, p.SpeedSelectClicks)
|
||||
} else {
|
||||
tabContent = connect4LobbyContent(p)
|
||||
}
|
||||
|
||||
return h.Main(h.Class("max-w-md mx-auto mt-8 text-center"),
|
||||
authSection,
|
||||
h.H1(h.Class("text-3xl font-bold mb-4"), StealthTitle("")),
|
||||
h.Div(h.Class("tabs tabs-box mb-6 justify-center"),
|
||||
h.Button(h.Class(connect4Class), h.Type("button"), StealthTitle(""), p.TabClickConnect4),
|
||||
h.Button(h.Class(snakeClass), h.Type("button"), h.Text("~~~~"), p.TabClickSnake),
|
||||
),
|
||||
tabContent,
|
||||
)
|
||||
}
|
||||
|
||||
func connect4LobbyContent(p LobbyProps) h.H {
|
||||
return h.Div(
|
||||
h.P(h.Class("mb-4"), h.Text("Start a new session")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
p.NicknameBind,
|
||||
h.Attr("required"),
|
||||
p.CreateGameKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Create Game"),
|
||||
p.CreateGameClick,
|
||||
),
|
||||
),
|
||||
GameList(p.UserGames, p.DeleteGameClick),
|
||||
)
|
||||
}
|
||||
|
||||
func NicknamePrompt(nicknameBind, setNicknameKeyDown, setNicknameClick h.H) h.H {
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Join Game")),
|
||||
h.P(h.Class("mb-4"), h.Text("Enter your nickname to join the game.")),
|
||||
h.Form(
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
h.Attr("required"),
|
||||
h.Attr("autofocus"),
|
||||
setNicknameKeyDown,
|
||||
),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Join"),
|
||||
setNicknameClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func GameJoinPrompt(loginClick, guestClick, registerClick h.H) h.H {
|
||||
return h.Main(h.Class("max-w-sm mx-auto mt-8 text-center"),
|
||||
h.H1(h.Class("text-3xl font-bold"), h.Text("Join Game")),
|
||||
h.P(h.Class("mb-4"), h.Text("Log in to track your game history, or continue as a guest.")),
|
||||
h.Div(h.Class("flex flex-col gap-2 my-4"),
|
||||
h.Button(
|
||||
h.Class("btn btn-primary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Login"),
|
||||
loginClick,
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-secondary w-full"),
|
||||
h.Type("button"),
|
||||
h.Text("Continue as Guest"),
|
||||
guestClick,
|
||||
),
|
||||
),
|
||||
h.P(h.Class("text-sm opacity-60"),
|
||||
h.Text("Don't have an account? "),
|
||||
h.A(h.Class("link"), h.Href("#"), h.Text("Register"), registerClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
112
ui/snakeboard.go
112
ui/snakeboard.go
@@ -1,112 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeBoard(sg *snake.SnakeGame) h.H {
|
||||
state := sg.State
|
||||
if state == nil || sg.Status != snake.StatusInProgress && sg.Status != snake.StatusFinished {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a lookup grid for rendering
|
||||
type cellInfo struct {
|
||||
snakeIdx int // -1 = empty, -2 = food
|
||||
isHead bool
|
||||
}
|
||||
grid := make([][]cellInfo, state.Height)
|
||||
for y := 0; y < state.Height; y++ {
|
||||
grid[y] = make([]cellInfo, state.Width)
|
||||
for x := 0; x < state.Width; x++ {
|
||||
grid[y][x] = cellInfo{snakeIdx: -1}
|
||||
}
|
||||
}
|
||||
|
||||
for fi := range state.Food {
|
||||
f := state.Food[fi]
|
||||
if f.X >= 0 && f.X < state.Width && f.Y >= 0 && f.Y < state.Height {
|
||||
grid[f.Y][f.X] = cellInfo{snakeIdx: -2}
|
||||
}
|
||||
}
|
||||
|
||||
for si, s := range state.Snakes {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
for bi, bp := range s.Body {
|
||||
if bp.X >= 0 && bp.X < state.Width && bp.Y >= 0 && bp.Y < state.Height {
|
||||
grid[bp.Y][bp.X] = cellInfo{snakeIdx: si, isHead: bi == 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cell size scales with grid dimensions
|
||||
cellSize := cellSizeForGrid(state.Width, state.Height)
|
||||
|
||||
var rows []h.H
|
||||
for y := 0; y < state.Height; y++ {
|
||||
var cells []h.H
|
||||
for x := 0; x < state.Width; x++ {
|
||||
ci := grid[y][x]
|
||||
class := "snake-cell"
|
||||
style := fmt.Sprintf("width:%dpx;height:%dpx;", cellSize, cellSize)
|
||||
|
||||
switch {
|
||||
case ci.snakeIdx == -2:
|
||||
class += " snake-food"
|
||||
case ci.snakeIdx >= 0:
|
||||
s := state.Snakes[ci.snakeIdx]
|
||||
colorIdx := ci.snakeIdx
|
||||
bg := ""
|
||||
if colorIdx < len(snake.SnakeColors) {
|
||||
bg = snake.SnakeColors[colorIdx]
|
||||
style += fmt.Sprintf("background:%s;", bg)
|
||||
}
|
||||
if !s.Alive {
|
||||
class += " snake-dead"
|
||||
}
|
||||
if ci.isHead {
|
||||
class += " snake-head"
|
||||
if bg != "" {
|
||||
style += fmt.Sprintf("box-shadow:0 0 8px %s;", bg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells = append(cells, h.Div(h.Class(class), h.Attr("style", style)))
|
||||
}
|
||||
rowAttrs := append([]h.H{h.Class("snake-row")}, cells...)
|
||||
rows = append(rows, h.Div(rowAttrs...))
|
||||
}
|
||||
|
||||
boardStyle := fmt.Sprintf("grid-template-columns:repeat(%d,1fr);", state.Width)
|
||||
attrs := []h.H{
|
||||
h.Class("snake-board"),
|
||||
h.Attr("style", boardStyle),
|
||||
}
|
||||
attrs = append(attrs, rows...)
|
||||
return h.Div(attrs...)
|
||||
}
|
||||
|
||||
func cellSizeForGrid(width, height int) int {
|
||||
maxDim := width
|
||||
if height > maxDim {
|
||||
maxDim = height
|
||||
}
|
||||
switch {
|
||||
case maxDim <= 15:
|
||||
return 28
|
||||
case maxDim <= 20:
|
||||
return 24
|
||||
case maxDim <= 30:
|
||||
return 20
|
||||
case maxDim <= 40:
|
||||
return 16
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Slot int `json:"slot"`
|
||||
Message string `json:"message"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
func SnakeChat(messages []ChatMessage, msgBind, sendClick, sendKeyDown h.H) h.H {
|
||||
var msgEls []h.H
|
||||
for _, m := range messages {
|
||||
color := "#666"
|
||||
if m.Slot >= 0 && m.Slot < len(snake.SnakeColors) {
|
||||
color = snake.SnakeColors[m.Slot]
|
||||
}
|
||||
msgEls = append(msgEls, h.Div(h.Class("snake-chat-msg"),
|
||||
h.Span(
|
||||
h.Attr("style", fmt.Sprintf("color:%s;font-weight:bold;", color)),
|
||||
h.Text(m.Nickname+": "),
|
||||
),
|
||||
h.Span(h.Text(m.Message)),
|
||||
))
|
||||
}
|
||||
|
||||
// Auto-scroll chat history to bottom on new messages
|
||||
autoScroll := h.Script(h.Text(`
|
||||
(function(){
|
||||
var el = document.querySelector('.snake-chat-history');
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
new MutationObserver(function(){ el.scrollTop = el.scrollHeight; })
|
||||
.observe(el, {childList:true, subtree:true});
|
||||
})();
|
||||
`))
|
||||
|
||||
historyAttrs := []h.H{h.Class("snake-chat-history")}
|
||||
historyAttrs = append(historyAttrs, msgEls...)
|
||||
historyAttrs = append(historyAttrs, autoScroll)
|
||||
|
||||
return h.Div(h.Class("snake-chat"),
|
||||
h.Div(historyAttrs...),
|
||||
h.Div(h.Class("snake-chat-input"),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Attr("placeholder", "Chat..."),
|
||||
h.Attr("autocomplete", "off"),
|
||||
// Prevent key events from bubbling to the game's window-level handler
|
||||
h.Attr("onkeydown", "event.stopPropagation()"),
|
||||
msgBind,
|
||||
sendKeyDown,
|
||||
),
|
||||
h.Button(h.Type("button"), h.Text("Send"), sendClick),
|
||||
),
|
||||
)
|
||||
}
|
||||
124
ui/snakelobby.go
124
ui/snakelobby.go
@@ -1,124 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeLobbyTab(nicknameBind h.H, soloClicks, multiClicks []h.H, activeGames []*snake.SnakeGame, selectedSpeedIndex int, speedSelectClicks []h.H) h.H {
|
||||
// Solo play buttons
|
||||
var soloButtons []h.H
|
||||
for i, preset := range snake.GridPresets {
|
||||
var click h.H
|
||||
if i < len(soloClicks) {
|
||||
click = soloClicks[i]
|
||||
}
|
||||
soloButtons = append(soloButtons,
|
||||
h.Button(
|
||||
h.Class("btn btn-secondary"),
|
||||
h.Type("button"),
|
||||
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||
click,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Multiplayer buttons
|
||||
var multiButtons []h.H
|
||||
for i, preset := range snake.GridPresets {
|
||||
var click h.H
|
||||
if i < len(multiClicks) {
|
||||
click = multiClicks[i]
|
||||
}
|
||||
multiButtons = append(multiButtons,
|
||||
h.Button(
|
||||
h.Class("btn btn-primary"),
|
||||
h.Type("button"),
|
||||
h.Text(fmt.Sprintf("%s (%d×%d)", preset.Name, preset.Width, preset.Height)),
|
||||
click,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
nicknameField := h.Div(h.Class("mb-4"),
|
||||
h.FieldSet(h.Class("fieldset"),
|
||||
h.Label(h.Class("label"), h.Text("Your Nickname"), h.Attr("for", "snake-nickname")),
|
||||
h.Input(
|
||||
h.Class("input input-bordered w-full"),
|
||||
h.ID("snake-nickname"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter your nickname"),
|
||||
nicknameBind,
|
||||
h.Attr("required"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Speed selector
|
||||
var speedButtons []h.H
|
||||
for i, preset := range snake.SpeedPresets {
|
||||
btnClass := "btn btn-sm"
|
||||
if i == selectedSpeedIndex {
|
||||
btnClass += " btn-active"
|
||||
}
|
||||
var click h.H
|
||||
if i < len(speedSelectClicks) {
|
||||
click = speedSelectClicks[i]
|
||||
}
|
||||
speedButtons = append(speedButtons, h.Button(
|
||||
h.Class(btnClass),
|
||||
h.Type("button"),
|
||||
h.Text(preset.Name),
|
||||
click,
|
||||
))
|
||||
}
|
||||
speedSelector := h.Div(h.Class("mb-4"),
|
||||
h.Label(h.Class("label"), h.Text("Speed")),
|
||||
h.Div(append([]h.H{h.Class("btn-group")}, speedButtons...)...),
|
||||
)
|
||||
|
||||
soloSection := h.Div(h.Class("mb-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Play Solo")),
|
||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, soloButtons...)...),
|
||||
)
|
||||
|
||||
multiSection := h.Div(h.Class("mb-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2"), h.Text("Create Multiplayer Game")),
|
||||
h.Div(append([]h.H{h.Class("flex gap-2 justify-center")}, multiButtons...)...),
|
||||
)
|
||||
|
||||
var gameListEl h.H
|
||||
if len(activeGames) > 0 {
|
||||
var items []h.H
|
||||
for _, g := range activeGames {
|
||||
playerCount := g.PlayerCount()
|
||||
sizeLabel := fmt.Sprintf("%d×%d", g.State.Width, g.State.Height)
|
||||
statusLabel := "Waiting"
|
||||
if g.Status == snake.StatusCountdown {
|
||||
statusLabel = "Starting soon"
|
||||
}
|
||||
items = append(items, h.A(
|
||||
h.Href("/snake/"+g.ID),
|
||||
h.Class("flex justify-between items-center p-3 bg-base-200 rounded-lg hover:bg-base-300 no-underline text-base-content"),
|
||||
h.Span(h.Text(fmt.Sprintf("%s — %d/8 players", sizeLabel, playerCount))),
|
||||
h.Span(h.Class("text-sm opacity-60"), h.Text(statusLabel)),
|
||||
))
|
||||
}
|
||||
listAttrs := []h.H{h.Class("flex flex-col gap-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
gameListEl = h.Div(h.Class("mt-6"),
|
||||
h.H3(h.Class("text-lg font-bold mb-2 text-center"), h.Text("Join a Game")),
|
||||
h.Div(listAttrs...),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
nicknameField,
|
||||
speedSelector,
|
||||
soloSection,
|
||||
multiSection,
|
||||
gameListEl,
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/c4/snake"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func SnakeStatusBanner(sg *snake.SnakeGame, mySlot int, rematchClick h.H) h.H {
|
||||
switch sg.Status {
|
||||
case snake.StatusWaitingForPlayers:
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||
h.Text("Ready?"),
|
||||
)
|
||||
}
|
||||
return h.Div(h.Class("alert bg-base-200 text-xl font-bold"),
|
||||
h.Text("Waiting for players..."),
|
||||
)
|
||||
|
||||
case snake.StatusCountdown:
|
||||
remaining := time.Until(sg.CountdownEnd)
|
||||
secs := int(math.Ceil(remaining.Seconds()))
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
return h.Div(h.Class("alert alert-info text-xl font-bold"),
|
||||
h.Text(fmt.Sprintf("Starting in %d...", secs)),
|
||||
)
|
||||
|
||||
case snake.StatusInProgress:
|
||||
if sg.State != nil && mySlot >= 0 && mySlot < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[mySlot]
|
||||
if s != nil && !s.Alive {
|
||||
return h.Div(h.Class("alert alert-error text-xl font-bold"),
|
||||
h.Text("You're out!"),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Show score during single player gameplay
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||
h.Text(fmt.Sprintf("Score: %d", sg.Score)),
|
||||
)
|
||||
}
|
||||
return h.Div(h.Class("alert alert-success text-xl font-bold"),
|
||||
h.Text("Go!"),
|
||||
)
|
||||
|
||||
case snake.StatusFinished:
|
||||
var msg string
|
||||
var class string
|
||||
|
||||
if sg.Mode == snake.ModeSinglePlayer {
|
||||
msg = fmt.Sprintf("Game Over! Score: %d", sg.Score)
|
||||
class = "alert alert-info text-xl font-bold"
|
||||
} else if sg.Winner != nil {
|
||||
if sg.Winner.Slot == mySlot {
|
||||
msg = "You win!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else {
|
||||
msg = sg.Winner.Nickname + " wins!"
|
||||
class = "alert alert-error text-xl font-bold"
|
||||
}
|
||||
} else {
|
||||
msg = "It's a draw!"
|
||||
class = "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
|
||||
content := []h.H{h.Class(class), h.Text(msg)}
|
||||
|
||||
if sg.RematchGameID != nil {
|
||||
content = append(content,
|
||||
h.A(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Href("/snake/"+*sg.RematchGameID),
|
||||
h.Text("Join Rematch"),
|
||||
),
|
||||
)
|
||||
} else if rematchClick != nil {
|
||||
content = append(content,
|
||||
h.Button(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Type("button"),
|
||||
h.Text("Play again"),
|
||||
rematchClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return h.Div(content...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SnakePlayerList(sg *snake.SnakeGame, mySlot int) h.H {
|
||||
var items []h.H
|
||||
|
||||
for i, p := range sg.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
colorHex := "#666"
|
||||
if i < len(snake.SnakeColors) {
|
||||
colorHex = snake.SnakeColors[i]
|
||||
}
|
||||
|
||||
name := p.Nickname
|
||||
if i == mySlot {
|
||||
name += " (You)"
|
||||
}
|
||||
|
||||
var statusEl h.H
|
||||
if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished {
|
||||
if sg.State != nil && i < len(sg.State.Snakes) {
|
||||
s := sg.State.Snakes[i]
|
||||
if s != nil {
|
||||
if s.Alive {
|
||||
length := len(s.Body)
|
||||
statusEl = h.Span(h.Class("text-sm opacity-60"), h.Text(fmt.Sprintf(" (%d)", length)))
|
||||
} else {
|
||||
statusEl = h.Span(h.Class("text-sm opacity-40"), h.Text(" (dead)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chipStyle := fmt.Sprintf("width:16px;height:16px;border-radius:50%%;background:%s;display:inline-block;", colorHex)
|
||||
|
||||
items = append(items, h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Attr("style", chipStyle)),
|
||||
h.Span(h.Text(name)),
|
||||
statusEl,
|
||||
))
|
||||
}
|
||||
|
||||
listAttrs := []h.H{h.Class("flex flex-wrap gap-4 mb-2")}
|
||||
listAttrs = append(listAttrs, items...)
|
||||
return h.Div(listAttrs...)
|
||||
}
|
||||
|
||||
func SnakeInviteLink(gameID string) h.H {
|
||||
fullURL := getBaseURL() + "/snake/" + gameID
|
||||
return h.Div(h.Class("mt-4 text-center"),
|
||||
h.P(h.Text("Share this link to invite players:")),
|
||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||
h.Text(fullURL),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-sm mt-2"),
|
||||
h.Type("button"),
|
||||
h.Text("Copy Link"),
|
||||
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||
),
|
||||
)
|
||||
}
|
||||
141
ui/status.go
141
ui/status.go
@@ -1,141 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func StatusBanner(g *game.Game, myColor int, playAgainClick h.H) h.H {
|
||||
var message string
|
||||
var class string
|
||||
|
||||
switch g.Status {
|
||||
case game.StatusWaitingForPlayer:
|
||||
message = "Waiting for opponent..."
|
||||
class = "alert bg-base-200 text-xl font-bold"
|
||||
case game.StatusInProgress:
|
||||
if g.CurrentTurn == myColor {
|
||||
message = "Your turn!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else {
|
||||
opponentName := getOpponentName(g, myColor)
|
||||
message = opponentName + "'s turn"
|
||||
class = "alert bg-base-200 text-xl font-bold"
|
||||
}
|
||||
case game.StatusWon:
|
||||
if g.Winner != nil && g.Winner.Color == myColor {
|
||||
message = "You win!"
|
||||
class = "alert alert-success text-xl font-bold"
|
||||
} else if g.Winner != nil {
|
||||
message = g.Winner.Nickname + " wins!"
|
||||
class = "alert alert-error text-xl font-bold"
|
||||
}
|
||||
case game.StatusDraw:
|
||||
message = "It's a draw!"
|
||||
class = "alert alert-warning text-xl font-bold"
|
||||
}
|
||||
|
||||
content := []h.H{
|
||||
h.Class(class),
|
||||
h.Text(message),
|
||||
}
|
||||
|
||||
// Show rematch options for finished games
|
||||
if g.IsFinished() {
|
||||
if g.RematchGameID != nil {
|
||||
content = append(content,
|
||||
h.A(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Href("/game/"+*g.RematchGameID),
|
||||
h.Text("Join Rematch"),
|
||||
),
|
||||
)
|
||||
} else if playAgainClick != nil {
|
||||
content = append(content,
|
||||
h.Button(
|
||||
h.Class("btn btn-sm bg-white text-gray-800 border-none ml-4"),
|
||||
h.Type("button"),
|
||||
h.Text("Play again"),
|
||||
playAgainClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return h.Div(content...)
|
||||
}
|
||||
|
||||
func getOpponentName(g *game.Game, myColor int) string {
|
||||
for _, p := range g.Players {
|
||||
if p != nil && p.Color != myColor {
|
||||
return p.Nickname
|
||||
}
|
||||
}
|
||||
return "Opponent"
|
||||
}
|
||||
|
||||
func PlayerInfo(g *game.Game, myColor int) h.H {
|
||||
var myName, opponentName string
|
||||
var myColorClass, opponentColorClass string
|
||||
|
||||
for _, p := range g.Players {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if p.Color == myColor {
|
||||
myName = p.Nickname
|
||||
if p.Color == 1 {
|
||||
myColorClass = "red"
|
||||
} else {
|
||||
myColorClass = "yellow"
|
||||
}
|
||||
} else {
|
||||
opponentName = p.Nickname
|
||||
if p.Color == 1 {
|
||||
opponentColorClass = "red"
|
||||
} else {
|
||||
opponentColorClass = "yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opponentName == "" {
|
||||
opponentName = "Waiting..."
|
||||
}
|
||||
|
||||
return h.Div(h.Class("flex gap-8 mb-2"),
|
||||
h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Class("player-chip "+myColorClass)),
|
||||
h.Span(h.Text(myName+" (You)")),
|
||||
),
|
||||
h.Div(h.Class("flex items-center gap-2"),
|
||||
h.Span(h.Class("player-chip "+opponentColorClass)),
|
||||
h.Span(h.Text(opponentName)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func getBaseURL() string {
|
||||
if url := os.Getenv("APP_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
return "https://games.adriatica.io"
|
||||
}
|
||||
|
||||
func InviteLink(gameID string) h.H {
|
||||
fullURL := getBaseURL() + "/game/" + gameID
|
||||
return h.Div(h.Class("mt-4 text-center"),
|
||||
h.P(h.Text("Share this link with your opponent:")),
|
||||
h.Div(h.Class("bg-base-200 p-4 rounded-lg font-mono break-all my-2"),
|
||||
h.Text(fullURL),
|
||||
),
|
||||
h.Button(
|
||||
h.Class("btn btn-sm mt-2"),
|
||||
h.Type("button"),
|
||||
h.Text("Copy Link"),
|
||||
h.Attr("onclick", "navigator.clipboard.writeText('"+fullURL+"')"),
|
||||
),
|
||||
)
|
||||
}
|
||||
10
version/version.go
Normal file
10
version/version.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Package version holds build-time version information injected via ldflags.
|
||||
package version
|
||||
|
||||
// Version and Commit are set at build time via:
|
||||
//
|
||||
// -ldflags "-X github.com/ryanhamamura/games/version.Version=... -X github.com/ryanhamamura/games/version.Commit=..."
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
)
|
||||
Reference in New Issue
Block a user