Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
This commit is contained in:
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/mixmaker-api ./cmd/api
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates wget && addgroup -S mixmaker && adduser -S -G mixmaker mixmaker
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/mixmaker-api /usr/local/bin/mixmaker-api
|
||||
COPY migrations ./migrations
|
||||
USER mixmaker
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["mixmaker-api"]
|
||||
52
backend/README.md
Normal file
52
backend/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Mixmaker backend
|
||||
|
||||
Go/PostgreSQL API organized as domain, application, and inbound/outbound adapters.
|
||||
|
||||
## Run
|
||||
|
||||
Required environment:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `DISCORD_CLIENT_ID`
|
||||
- `DISCORD_CLIENT_SECRET`
|
||||
- `DISCORD_REDIRECT_URL`
|
||||
- `ADMIN_DISCORD_IDS` (comma-separated Discord IDs)
|
||||
|
||||
Optional environment:
|
||||
|
||||
- `HTTP_ADDR=:8080`
|
||||
- `FRONTEND_URL=/`
|
||||
- `SESSION_COOKIE_NAME=mixmaker_session`
|
||||
- `SESSION_TTL=168h`
|
||||
- `COOKIE_SECURE=true` (set `false` only for local HTTP)
|
||||
- `MIGRATIONS_DIR=migrations`
|
||||
|
||||
Apply migrations and run:
|
||||
|
||||
```sh
|
||||
go run ./cmd/api migrate
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
The same commands work with the container image:
|
||||
|
||||
```sh
|
||||
docker run --rm ... mixmaker-api migrate
|
||||
docker run --rm ... mixmaker-api
|
||||
```
|
||||
|
||||
`GET /healthz` is a process health check and `GET /readyz` checks PostgreSQL.
|
||||
Authenticated realtime updates are available from
|
||||
`GET /api/events/stream?topic=event:<event-id>` as Server-Sent Events.
|
||||
|
||||
## Security and behavior
|
||||
|
||||
Discord access tokens are used only during callback and are not stored. The API
|
||||
creates opaque server-side sessions and sends an HttpOnly, SameSite=Lax cookie.
|
||||
Initial administrators are promoted only when their Discord ID is present in
|
||||
`ADMIN_DISCORD_IDS`; existing administrators are never demoted by login.
|
||||
|
||||
Draft and series writes use persisted versions to reject stale repeated
|
||||
commands. RSVP overrides, captain assignments, balancing selections, tosses,
|
||||
draft actions, and results retain actor information in domain state or the
|
||||
append-only audit log.
|
||||
111
backend/cmd/api/main.go
Normal file
111
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/adapter/httpapi"
|
||||
"mixmaker/backend/internal/adapter/postgres"
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/realtime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
databaseURL := required("DATABASE_URL")
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
dir := env("MIGRATIONS_DIR", "migrations")
|
||||
if err := postgres.Migrate(ctx, databaseURL, dir); err != nil {
|
||||
slog.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("migrations applied")
|
||||
return
|
||||
}
|
||||
|
||||
store, err := postgres.Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
slog.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer store.Close()
|
||||
hub := realtime.New()
|
||||
service := application.New(store, hub)
|
||||
cfg := httpapi.Config{
|
||||
DiscordClientID: required("DISCORD_CLIENT_ID"),
|
||||
DiscordClientSecret: required("DISCORD_CLIENT_SECRET"),
|
||||
DiscordRedirectURL: required("DISCORD_REDIRECT_URL"),
|
||||
FrontendURL: env("FRONTEND_URL", "/"),
|
||||
CookieName: env("SESSION_COOKIE_NAME", "mixmaker_session"),
|
||||
SecureCookies: env("COOKIE_SECURE", "true") == "true",
|
||||
SessionTTL: durationEnv("SESSION_TTL", 7*24*time.Hour),
|
||||
AdminDiscordIDs: csvSet(os.Getenv("ADMIN_DISCORD_IDS")),
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: env("HTTP_ADDR", ":8080"),
|
||||
Handler: httpapi.New(service, store, hub, cfg),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
slog.Info("API listening", "address", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
stop, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
<-stop.Done()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("graceful shutdown failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func required(key string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
slog.Error("required environment variable missing", "key", key)
|
||||
os.Exit(2)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func csvSet(value string) map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
out[item] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func durationEnv(key string, fallback time.Duration) time.Duration {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
slog.Error("invalid duration environment variable", "key", key, "value", value)
|
||||
os.Exit(2)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
17
backend/go.mod
Normal file
17
backend/go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module mixmaker/backend
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
30
backend/go.sum
Normal file
30
backend/go.sum
Normal file
@@ -0,0 +1,30 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
|
||||
684
backend/internal/adapter/httpapi/server.go
Normal file
684
backend/internal/adapter/httpapi/server.go
Normal file
@@ -0,0 +1,684 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
"mixmaker/backend/internal/realtime"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DiscordClientID, DiscordClientSecret, DiscordRedirectURL string
|
||||
FrontendURL, CookieName string
|
||||
SecureCookies bool
|
||||
SessionTTL time.Duration
|
||||
AdminDiscordIDs map[string]bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
service *application.Service
|
||||
store application.Store
|
||||
hub *realtime.Hub
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type identity struct {
|
||||
account domain.Account
|
||||
player domain.Player
|
||||
session string
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
|
||||
func New(service *application.Service, store application.Store, hub *realtime.Hub, cfg Config) http.Handler {
|
||||
s := &Server{service: service, store: store, hub: hub, cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.Get("/readyz", s.ready)
|
||||
r.Get("/api/auth/discord", s.discordLogin)
|
||||
r.Get("/api/auth/discord/callback", s.discordCallback)
|
||||
r.Post("/api/auth/logout", s.logout)
|
||||
r.Group(func(api chi.Router) {
|
||||
api.Use(s.authenticate)
|
||||
api.Get("/api/auth/me", s.me)
|
||||
api.Get("/api/me", s.me)
|
||||
api.Patch("/api/me/player", s.updateProfile)
|
||||
api.Get("/api/players", s.players)
|
||||
api.Get("/api/events", s.events)
|
||||
api.Post("/api/events", s.createEvent)
|
||||
api.Get("/api/events/{eventID}", s.getEvent)
|
||||
api.Put("/api/events/{eventID}", s.updateEvent)
|
||||
api.Get("/api/events/{eventID}/tournament", s.getEventTournament)
|
||||
api.Get("/api/events/{eventID}/rsvps", s.rsvps)
|
||||
api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP)
|
||||
api.Post("/api/events/{eventID}/balance", s.balance)
|
||||
api.Put("/api/events/{eventID}/teams", s.selectBalance)
|
||||
api.Get("/api/events/{eventID}/teams", s.teams)
|
||||
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
||||
api.Post("/api/rulesets", s.saveRuleset)
|
||||
api.Get("/api/rulesets", s.listRulesets)
|
||||
api.Get("/api/rulesets/{id}", s.getRuleset)
|
||||
api.Post("/api/coin-toss", s.coinToss)
|
||||
api.Post("/api/drafts/maps", s.createMapDraft)
|
||||
api.Post("/api/drafts/{id}/map-bans", s.mapBan)
|
||||
api.Post("/api/drafts/heroes", s.createHeroDraft)
|
||||
api.Post("/api/drafts/{id}/hero-bans", s.heroBan)
|
||||
api.Post("/api/tournaments", s.createTournament)
|
||||
api.Get("/api/tournaments/{id}", s.getTournament)
|
||||
api.Get("/api/series/{id}", s.getSeries)
|
||||
api.Post("/api/series/{id}/results", s.recordResult)
|
||||
api.Patch("/api/series/{id}/results/{index}", s.correctResult)
|
||||
api.Get("/api/events/stream", s.stream)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.Ready(r.Context()); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Server) cookie(name, value string, expires time.Time) *http.Cookie {
|
||||
return &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, Expires: expires}
|
||||
}
|
||||
|
||||
func (s *Server) discordLogin(w http.ResponseWriter, r *http.Request) {
|
||||
state := randomToken()
|
||||
http.SetCookie(w, s.cookie("mixmaker_oauth_state", state, time.Now().Add(10*time.Minute)))
|
||||
q := url.Values{"client_id": {s.cfg.DiscordClientID}, "redirect_uri": {s.cfg.DiscordRedirectURL}, "response_type": {"code"}, "scope": {"identify"}, "state": {state}}
|
||||
http.Redirect(w, r, "https://discord.com/oauth2/authorize?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) {
|
||||
stateCookie, err := r.Cookie("mixmaker_oauth_state")
|
||||
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
|
||||
writeError(w, fmt.Errorf("%w: invalid OAuth state", domain.ErrInvalid))
|
||||
return
|
||||
}
|
||||
form := url.Values{"client_id": {s.cfg.DiscordClientID}, "client_secret": {s.cfg.DiscordClientSecret}, "grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {s.cfg.DiscordRedirectURL}}
|
||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil {
|
||||
writeError(w, fmt.Errorf("Discord token exchange failed"))
|
||||
return
|
||||
}
|
||||
req, _ = http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var user struct{ ID, Username, Avatar string }
|
||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&user) != nil {
|
||||
writeError(w, fmt.Errorf("Discord identity request failed"))
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
account := domain.Account{ID: application.NewID(), DiscordID: user.ID, Username: user.Username, AvatarURL: user.Avatar, CreatedAt: now}
|
||||
account, _, err = s.store.UpsertDiscordAccount(r.Context(), account, s.cfg.AdminDiscordIDs[user.ID])
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
session := randomToken()
|
||||
ttl := s.cfg.SessionTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
expires := now.Add(ttl)
|
||||
if err := s.store.CreateSession(r.Context(), session, account.ID, expires); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, s.cookie(s.cfg.CookieName, session, expires))
|
||||
http.SetCookie(w, s.cookie("mixmaker_oauth_state", "", time.Unix(1, 0)))
|
||||
http.Redirect(w, r, s.cfg.FrontendURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(s.cfg.CookieName); err == nil {
|
||||
_ = s.store.DeleteSession(r.Context(), c.Value)
|
||||
}
|
||||
http.SetCookie(w, s.cookie(s.cfg.CookieName, "", time.Unix(1, 0)))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(s.cfg.CookieName)
|
||||
if err != nil {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
a, p, err := s.service.Authenticate(r.Context(), c.Value)
|
||||
if err != nil {
|
||||
writeError(w, domain.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{a, p, c.Value})))
|
||||
})
|
||||
}
|
||||
|
||||
func who(r *http.Request) identity { return r.Context().Value(identityKey{}).(identity) }
|
||||
func requireAdmin(r *http.Request) error {
|
||||
if !who(r).account.IsAdmin() {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
id := who(r)
|
||||
writeJSON(w, 200, map[string]any{"account": id.account, "player": id.player})
|
||||
}
|
||||
|
||||
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Ratings domain.Ratings `json:"ratings"`
|
||||
PreferredRoles []domain.Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
id := who(r)
|
||||
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) players(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.ListPlayers(r.Context())
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
from := time.Now().UTC()
|
||||
if raw := r.URL.Query().Get("from"); raw != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
from = parsed
|
||||
}
|
||||
}
|
||||
out, err := s.store.ListEvents(r.Context(), from)
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) createEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var in domain.Event
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.CreateEvent(r.Context(), who(r).account, in)
|
||||
respond(w, out, err, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetEvent(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) updateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var in domain.Event
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.UpdateEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) getEventTournament(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetTournamentByEvent(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) rsvps(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.ListRSVPs(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Status domain.RSVPStatus `json:"status"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
id := who(r)
|
||||
out, err := s.service.SetRSVP(r.Context(), id.account, id.player, chi.URLParam(r, "eventID"), chi.URLParam(r, "playerID"), in.Status)
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) balance(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.service.Balance(r.Context(), who(r).account, chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) selectBalance(w http.ResponseWriter, r *http.Request) {
|
||||
var in domain.BalanceCandidate
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
err := s.service.SelectBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in)
|
||||
respond(w, map[string]string{"status": "selected"}, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) teams(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.ListTeams(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) assignCaptain(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
PlayerID string `json:"playerId"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.AssignCaptain(r.Context(), who(r).account, chi.URLParam(r, "teamID"), in.PlayerID)
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var rules domain.Ruleset
|
||||
if !decode(w, r, &rules) {
|
||||
return
|
||||
}
|
||||
if rules.ID == "" {
|
||||
rules.ID = application.NewID()
|
||||
}
|
||||
if err := rules.Validate(); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.store.SaveRuleset(r.Context(), rules)
|
||||
respond(w, out, err, 201)
|
||||
}
|
||||
|
||||
func (s *Server) getRuleset(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetRuleset(r.Context(), chi.URLParam(r, "id"))
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) listRulesets(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.ListRulesets(r.Context())
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) coinToss(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamAID string `json:"teamAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
Seed string `json:"seed"`
|
||||
EventID string `json:"eventId"`
|
||||
ActingTeamID string `json:"actingTeamId"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if err := s.authorizeTeam(r, in.EventID, in.ActingTeamID); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := domain.TossCoin(in.TeamAID, in.TeamBID, in.Seed, time.Now().UTC())
|
||||
if err == nil {
|
||||
_ = s.store.AppendAudit(r.Context(), who(r).account.ID, "coin.tossed", in.TeamAID+":"+in.TeamBID, out)
|
||||
}
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) createMapDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID, FirstTeamID, RulesetID string
|
||||
PoolIndex int
|
||||
TeamIDs [2]string
|
||||
PlayedMaps []string
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
rules, err := s.store.GetRuleset(r.Context(), in.RulesetID)
|
||||
if err == nil && (in.PoolIndex < 0 || in.PoolIndex >= len(rules.MapPools)) {
|
||||
err = fmt.Errorf("%w: map pool index is outside the ruleset", domain.ErrInvalid)
|
||||
}
|
||||
var pool []string
|
||||
if err == nil {
|
||||
for _, name := range rules.MapPools[in.PoolIndex] {
|
||||
if !slices.Contains(in.PlayedMaps, name) {
|
||||
pool = append(pool, name)
|
||||
}
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
err = fmt.Errorf("%w: no unplayed maps remain in pool", domain.ErrInvalid)
|
||||
}
|
||||
}
|
||||
var draft *domain.MapDraft
|
||||
if err == nil {
|
||||
draft, err = domain.NewMapDraft(pool, in.FirstTeamID, in.TeamIDs)
|
||||
}
|
||||
if err == nil {
|
||||
if in.ID == "" {
|
||||
in.ID = application.NewID()
|
||||
}
|
||||
err = s.store.SaveDraft(r.Context(), in.ID, "map", draft, -1)
|
||||
}
|
||||
respond(w, map[string]any{"id": in.ID, "draft": draft, "version": 0}, err, 201)
|
||||
}
|
||||
|
||||
func (s *Server) mapBan(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamID, Map, EventID string
|
||||
Version int
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if err := s.authorizeTeam(r, in.EventID, in.TeamID); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var draft domain.MapDraft
|
||||
kind, version, err := s.store.GetDraft(r.Context(), chi.URLParam(r, "id"), &draft)
|
||||
if err == nil && (kind != "map" || version != in.Version) {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
if err == nil {
|
||||
err = draft.Ban(in.TeamID, in.Map, who(r).account.ID, time.Now().UTC())
|
||||
}
|
||||
if err == nil {
|
||||
err = s.store.SaveDraft(r.Context(), chi.URLParam(r, "id"), "map", draft, version)
|
||||
}
|
||||
respond(w, map[string]any{"draft": draft, "version": version + 1}, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) createHeroDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID, FirstTeamID, RulesetID string
|
||||
TeamIDs [2]string
|
||||
PreviousBans map[string][]string
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
rules, err := s.store.GetRuleset(r.Context(), in.RulesetID)
|
||||
var draft *domain.HeroDraft
|
||||
if err == nil {
|
||||
draft, err = domain.NewHeroDraft(rules.Heroes, in.TeamIDs, in.FirstTeamID, rules.HeroBansPerTeam, in.PreviousBans)
|
||||
}
|
||||
if err == nil {
|
||||
if in.ID == "" {
|
||||
in.ID = application.NewID()
|
||||
}
|
||||
err = s.store.SaveDraft(r.Context(), in.ID, "hero", draft, -1)
|
||||
}
|
||||
respond(w, map[string]any{"id": in.ID, "draft": draft, "version": 0}, err, 201)
|
||||
}
|
||||
|
||||
func (s *Server) heroBan(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamID, Hero, EventID string
|
||||
Version int
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if err := s.authorizeTeam(r, in.EventID, in.TeamID); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var draft domain.HeroDraft
|
||||
kind, version, err := s.store.GetDraft(r.Context(), chi.URLParam(r, "id"), &draft)
|
||||
if err == nil && (kind != "hero" || version != in.Version) {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
if err == nil {
|
||||
err = draft.Ban(in.TeamID, in.Hero, who(r).account.ID, time.Now().UTC())
|
||||
}
|
||||
if err == nil {
|
||||
err = s.store.SaveDraft(r.Context(), chi.URLParam(r, "id"), "hero", draft, version)
|
||||
}
|
||||
respond(w, map[string]any{"draft": draft, "version": version + 1}, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error {
|
||||
id := who(r)
|
||||
if id.account.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
teams, err := s.store.ListTeams(r.Context(), eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, team := range teams {
|
||||
if team.ID == teamID && application.CanActForTeam(id.account, id.player, team) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
func (s *Server) createTournament(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID, EventID, Name string
|
||||
TeamIDs []string
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.ID == "" {
|
||||
in.ID = application.NewID()
|
||||
}
|
||||
t, err := domain.NewTournament(in.ID, in.EventID, in.Name, in.TeamIDs)
|
||||
if err == nil {
|
||||
_, err = s.store.SaveTournament(r.Context(), *t)
|
||||
}
|
||||
if err == nil {
|
||||
for _, series := range t.Rounds[0] {
|
||||
if _, err = s.store.SaveSeries(r.Context(), series); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(w, t, err, 201)
|
||||
}
|
||||
|
||||
func (s *Server) getTournament(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetTournament(r.Context(), chi.URLParam(r, "id"))
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) getSeries(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) recordResult(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
MapName string `json:"mapName"`
|
||||
Outcome domain.MapOutcome `json:"outcome"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.RecordMap(r.Context(), who(r).account, chi.URLParam(r, "id"), in.MapName, in.Outcome, in.ExpectedVersion)
|
||||
if err == nil && out.WinnerTeamID != "" && out.TournamentID != "" {
|
||||
var t domain.Tournament
|
||||
t, err = s.store.GetTournament(r.Context(), out.TournamentID)
|
||||
if err == nil {
|
||||
for round := range t.Rounds {
|
||||
for match := range t.Rounds[round] {
|
||||
if t.Rounds[round][match].ID == out.ID {
|
||||
t.Rounds[round][match] = out
|
||||
err = t.Advance(round, match)
|
||||
if err == nil {
|
||||
_, err = s.store.SaveTournament(r.Context(), t)
|
||||
}
|
||||
if err == nil && round+1 < len(t.Rounds) {
|
||||
next := t.Rounds[round+1][match/2]
|
||||
if next.TeamAID != "" && next.TeamBID != "" {
|
||||
_, err = s.store.SaveSeries(r.Context(), next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) correctResult(w http.ResponseWriter, r *http.Request) {
|
||||
index, err := strconv.Atoi(chi.URLParam(r, "index"))
|
||||
if err != nil || index < 0 {
|
||||
writeError(w, fmt.Errorf("%w: invalid result index", domain.ErrInvalid))
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
MapName string `json:"mapName"`
|
||||
Outcome domain.MapOutcome `json:"outcome"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.CorrectMap(r.Context(), who(r).account, chi.URLParam(r, "id"), index, in.MapName, in.Outcome, in.ExpectedVersion)
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) stream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, errors.New("streaming unsupported"))
|
||||
return
|
||||
}
|
||||
topic := r.URL.Query().Get("topic")
|
||||
if topic == "" {
|
||||
topic = "*"
|
||||
}
|
||||
ch, cancel := s.hub.Subscribe(topic)
|
||||
defer cancel()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
fmt.Fprint(w, ": connected\n\n")
|
||||
flusher.Flush()
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
case event := <-ch:
|
||||
fmt.Fprintf(w, "event: update\ndata: %s\n\n", mustJSON(event))
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
writeError(w, fmt.Errorf("%w: %v", domain.ErrInvalid, err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, value any, err error, status int) {
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalid):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, domain.ErrUnauthorized):
|
||||
status = http.StatusUnauthorized
|
||||
case errors.Is(err, domain.ErrForbidden):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrDraftComplete):
|
||||
status = http.StatusConflict
|
||||
default:
|
||||
slog.Error("request failed", "error", err)
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
out, _ := json.Marshal(v)
|
||||
return out
|
||||
}
|
||||
446
backend/internal/adapter/postgres/store.go
Normal file
446
backend/internal/adapter/postgres/store.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Store struct{ pool *pgxpool.Pool }
|
||||
|
||||
func Open(ctx context.Context, url string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
func (s *Store) Ready(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account, admin bool) (domain.Account, domain.Player, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
role := domain.RolePlayer
|
||||
if admin {
|
||||
role = domain.RoleAdmin
|
||||
}
|
||||
err = tx.QueryRow(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(discord_id) DO UPDATE
|
||||
SET username=excluded.username,avatar_url=excluded.avatar_url,
|
||||
role=CASE WHEN accounts.role='admin' THEN accounts.role ELSE excluded.role END
|
||||
RETURNING id,discord_id,username,avatar_url,role,created_at`,
|
||||
account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt).
|
||||
Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
|
||||
RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,created_at,updated_at`,
|
||||
p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
if err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
return account, p, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, token, accountID string, expires time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO sessions(token_hash,account_id,expires_at) VALUES($1,$2,$3)`, HashToken(token), accountID, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash=$1`, HashToken(token))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Account, domain.Player, error) {
|
||||
var a domain.Account
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
|
||||
p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.created_at,p.updated_at
|
||||
FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id
|
||||
WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)).
|
||||
Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt,
|
||||
&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||
}
|
||||
return a, p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Player, error) {
|
||||
preferredRoles, _ := json.Marshal(p.PreferredRoles)
|
||||
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
|
||||
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
|
||||
preferred_roles=$6,preferred_player_ids=$7,updated_at=$8
|
||||
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
|
||||
return p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,account_id,display_name,tank_rating,damage_rating,support_rating,
|
||||
preferred_roles,preferred_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Player
|
||||
for rows.Next() {
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
&preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at`,
|
||||
e.ID, e.Name, e.Description, e.StartsAt, e.EndsAt, e.RegistrationDeadline, e.CreatedBy, e.CreatedAt, e.UpdatedAt).
|
||||
Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt)
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (s *Store) GetEvent(ctx context.Context, id string) (domain.Event, error) {
|
||||
var event domain.Event
|
||||
err := s.pool.QueryRow(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at
|
||||
FROM events WHERE id=$1`, id).
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt)
|
||||
return event, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateEvent(ctx context.Context, event domain.Event) (domain.Event, error) {
|
||||
err := s.pool.QueryRow(ctx, `UPDATE events
|
||||
SET name=$2,description=$3,starts_at=$4,ends_at=$5,registration_deadline=$6,updated_at=$7
|
||||
WHERE id=$1
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at`,
|
||||
event.ID, event.Name, event.Description, event.StartsAt, event.EndsAt, event.RegistrationDeadline, event.UpdatedAt).
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt)
|
||||
return event, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListEvents(ctx context.Context, from time.Time) ([]domain.Event, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at FROM events WHERE ends_at >= $1 ORDER BY starts_at`, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Event
|
||||
for rows.Next() {
|
||||
var e domain.Event
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRSVP(ctx context.Context, r domain.RSVP) (domain.RSVP, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(event_id,player_id) DO UPDATE
|
||||
SET status=excluded.status,actor_account_id=excluded.actor_account_id,source=excluded.source,updated_at=excluded.updated_at
|
||||
RETURNING event_id,player_id,status,actor_account_id,source,updated_at`,
|
||||
r.EventID, r.PlayerID, r.Status, r.ActorAccountID, r.Source, r.UpdatedAt).
|
||||
Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *Store) ListRSVPs(ctx context.Context, eventID string) ([]domain.RSVP, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT event_id,player_id,status,actor_account_id,source,updated_at FROM rsvps WHERE event_id=$1 ORDER BY player_id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.RSVP
|
||||
for rows.Next() {
|
||||
var r domain.RSVP
|
||||
if err := rows.Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveTeams(ctx context.Context, eventID string, teams []domain.Team) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM teams WHERE event_id=$1`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range teams {
|
||||
body, _ := json.Marshal(t.Slots)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO teams(id,event_id,name,captain_player_id,slots) VALUES($1,$2,$3,NULLIF($4,''),$5)`, t.ID, eventID, t.Name, t.CaptainPlayerID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) ListTeams(ctx context.Context, eventID string) ([]domain.Team, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,event_id,name,COALESCE(captain_player_id,''),slots FROM teams WHERE event_id=$1 ORDER BY id`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Team
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
var body []byte
|
||||
if err := rows.Scan(&t.ID, &t.EventID, &t.Name, &t.CaptainPlayerID, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &t.Slots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AssignCaptain(ctx context.Context, teamID, playerID string) (domain.Team, error) {
|
||||
var eventID string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT event_id FROM teams WHERE id=$1 AND slots @> $2::jsonb`, teamID, fmt.Sprintf(`[{"playerId":%q}]`, playerID)).Scan(&eventID); err != nil {
|
||||
return domain.Team{}, mapError(err)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `UPDATE teams SET captain_player_id=$2 WHERE id=$1`, teamID, playerID); err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
teams, err := s.ListTeams(ctx, eventID)
|
||||
for _, t := range teams {
|
||||
if t.ID == teamID {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
return domain.Team{}, domain.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *Store) SaveRuleset(ctx context.Context, r domain.Ruleset) (domain.Ruleset, error) {
|
||||
body, _ := json.Marshal(r)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO rulesets(id,name,body) VALUES($1,$2,$3)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name,body=excluded.body`, r.ID, r.Name, body)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *Store) ListRulesets(ctx context.Context) ([]domain.Ruleset, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT body FROM rulesets ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var rulesets []domain.Ruleset
|
||||
for rows.Next() {
|
||||
var body []byte
|
||||
var ruleset domain.Ruleset
|
||||
if err := rows.Scan(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &ruleset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rulesets = append(rulesets, ruleset)
|
||||
}
|
||||
return rulesets, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetRuleset(ctx context.Context, id string) (domain.Ruleset, error) {
|
||||
var body []byte
|
||||
var r domain.Ruleset
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM rulesets WHERE id=$1`, id).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &r)
|
||||
}
|
||||
return r, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) SaveSeries(ctx context.Context, series domain.Series) (domain.Series, error) {
|
||||
body, _ := json.Marshal(series)
|
||||
tag, err := s.pool.Exec(ctx, `INSERT INTO series(id,tournament_id,body,version) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version
|
||||
WHERE series.version=excluded.version-1`, series.ID, series.TournamentID, body, series.Version)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return series, err
|
||||
}
|
||||
|
||||
func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error) {
|
||||
var body []byte
|
||||
var out domain.Series
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM series WHERE id=$1`, id).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||
body, _ := json.Marshal(t)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body`, t.ID, t.EventID, body)
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) GetTournament(ctx context.Context, id string) (domain.Tournament, error) {
|
||||
var body []byte
|
||||
var out domain.Tournament
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM tournaments WHERE id=$1`, id).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) GetTournamentByEvent(ctx context.Context, eventID string) (domain.Tournament, error) {
|
||||
var body []byte
|
||||
var out domain.Tournament
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM tournaments WHERE event_id=$1 ORDER BY id DESC LIMIT 1`, eventID).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) SaveDraft(ctx context.Context, id, kind string, value any, expectedVersion int) error {
|
||||
body, _ := json.Marshal(value)
|
||||
if expectedVersion < 0 {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO draft_states(id,kind,body,version) VALUES($1,$2,$3,0)`, id, kind, body)
|
||||
return err
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE draft_states SET body=$3,version=version+1,updated_at=now()
|
||||
WHERE id=$1 AND kind=$2 AND version=$4`, id, kind, body, expectedVersion)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, int, error) {
|
||||
var kind string
|
||||
var version int
|
||||
var body []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT kind,version,body FROM draft_states WHERE id=$1`, id).Scan(&kind, &version, &body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, target)
|
||||
}
|
||||
return kind, version, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
|
||||
body, _ := json.Marshal(payload)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO audit_log(actor_account_id,action,subject_id,payload) VALUES($1,$2,$3,$4)`, actor, action, subject, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, poolURL, directory string) error {
|
||||
pool, err := pgxpool.New(ctx, poolURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
if _, err = pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations(version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(directory, "*.sql"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, file := range files {
|
||||
version := filepath.Base(file)
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, version).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
up := strings.Split(string(body), "-- +mixmaker Down")[0]
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, up); err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, version)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit(ctx)
|
||||
} else {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply %s: %w", version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
26
backend/internal/adapter/postgres/store_integration_test.go
Normal file
26
backend/internal/adapter/postgres/store_integration_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationsAndReadiness(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not configured")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.Ready(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
272
backend/internal/application/service.go
Normal file
272
backend/internal/application/service.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Ready(context.Context) error
|
||||
UpsertDiscordAccount(context.Context, domain.Account, bool) (domain.Account, domain.Player, error)
|
||||
AccountBySession(context.Context, string) (domain.Account, domain.Player, error)
|
||||
CreateSession(context.Context, string, string, time.Time) error
|
||||
DeleteSession(context.Context, string) error
|
||||
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
|
||||
ListPlayers(context.Context) ([]domain.Player, error)
|
||||
CreateEvent(context.Context, domain.Event) (domain.Event, error)
|
||||
GetEvent(context.Context, string) (domain.Event, error)
|
||||
UpdateEvent(context.Context, domain.Event) (domain.Event, error)
|
||||
ListEvents(context.Context, time.Time) ([]domain.Event, error)
|
||||
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error)
|
||||
ListRSVPs(context.Context, string) ([]domain.RSVP, error)
|
||||
SaveTeams(context.Context, string, []domain.Team) error
|
||||
ListTeams(context.Context, string) ([]domain.Team, error)
|
||||
AssignCaptain(context.Context, string, string) (domain.Team, error)
|
||||
SaveRuleset(context.Context, domain.Ruleset) (domain.Ruleset, error)
|
||||
ListRulesets(context.Context) ([]domain.Ruleset, error)
|
||||
GetRuleset(context.Context, string) (domain.Ruleset, error)
|
||||
SaveSeries(context.Context, domain.Series) (domain.Series, error)
|
||||
GetSeries(context.Context, string) (domain.Series, error)
|
||||
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
|
||||
GetTournament(context.Context, string) (domain.Tournament, error)
|
||||
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
|
||||
SaveDraft(context.Context, string, string, any, int) error
|
||||
GetDraft(context.Context, string, any) (string, int, error)
|
||||
AppendAudit(context.Context, string, string, string, any) error
|
||||
}
|
||||
|
||||
type Publisher interface{ Publish(topic string, value any) }
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Bus Publisher
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(store Store, bus Publisher) *Service {
|
||||
return &Service{Store: store, Bus: bus, Now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, session string) (domain.Account, domain.Player, error) {
|
||||
if session == "" {
|
||||
return domain.Account{}, domain.Player{}, domain.ErrUnauthorized
|
||||
}
|
||||
return s.Store.AccountBySession(ctx, session)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs []string) (domain.Player, error) {
|
||||
if actor.ID != current.AccountID {
|
||||
return domain.Player{}, domain.ErrForbidden
|
||||
}
|
||||
if err := ratings.Validate(); err != nil {
|
||||
return domain.Player{}, err
|
||||
}
|
||||
if displayName != "" {
|
||||
current.DisplayName = displayName
|
||||
}
|
||||
current.Ratings = ratings
|
||||
current.PreferredRoles = preferredRoles
|
||||
current.PreferredPlayerIDs = preferredPlayerIDs
|
||||
current.UpdatedAt = s.Now()
|
||||
if err := current.ValidatePreferences(); err != nil {
|
||||
return domain.Player{}, err
|
||||
}
|
||||
players, err := s.Store.ListPlayers(ctx)
|
||||
if err != nil {
|
||||
return domain.Player{}, err
|
||||
}
|
||||
knownPlayers := make(map[string]bool, len(players))
|
||||
for _, player := range players {
|
||||
knownPlayers[player.ID] = true
|
||||
}
|
||||
for _, playerID := range current.PreferredPlayerIDs {
|
||||
if !knownPlayers[playerID] {
|
||||
return domain.Player{}, fmt.Errorf("%w: preferred teammate does not exist", domain.ErrInvalid)
|
||||
}
|
||||
}
|
||||
return s.Store.UpdatePlayer(ctx, current)
|
||||
}
|
||||
|
||||
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.Event{}, domain.ErrForbidden
|
||||
}
|
||||
if event.RegistrationDeadline.IsZero() {
|
||||
event.RegistrationDeadline = event.StartsAt
|
||||
}
|
||||
event.ID, event.CreatedBy, event.CreatedAt, event.UpdatedAt = NewID(), actor.ID, s.Now(), s.Now()
|
||||
if err := event.Validate(); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
out, err := s.Store.CreateEvent(ctx, event)
|
||||
if err == nil {
|
||||
s.Bus.Publish("events", out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.Event{}, domain.ErrForbidden
|
||||
}
|
||||
current, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
if changes.RegistrationDeadline.IsZero() {
|
||||
changes.RegistrationDeadline = changes.StartsAt
|
||||
}
|
||||
current.Name = changes.Name
|
||||
current.Description = changes.Description
|
||||
current.StartsAt = changes.StartsAt
|
||||
current.EndsAt = changes.EndsAt
|
||||
current.RegistrationDeadline = changes.RegistrationDeadline
|
||||
current.UpdatedAt = s.Now()
|
||||
if err := current.Validate(); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
out, err := s.Store.UpdateEvent(ctx, current)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "event.updated", eventID, out)
|
||||
s.Bus.Publish("events", out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer domain.Player, eventID, playerID string, status domain.RSVPStatus) (domain.RSVP, error) {
|
||||
source := domain.SourcePlayer
|
||||
if playerID == "" {
|
||||
playerID = actorPlayer.ID
|
||||
}
|
||||
if playerID != actorPlayer.ID {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.RSVP{}, domain.ErrForbidden
|
||||
}
|
||||
source = domain.SourceAdmin
|
||||
}
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
return domain.RSVP{}, err
|
||||
}
|
||||
if !actor.IsAdmin() && s.Now().After(event.RegistrationDeadline) {
|
||||
return domain.RSVP{}, fmt.Errorf("%w: registration deadline has passed", domain.ErrConflict)
|
||||
}
|
||||
rsvp := domain.RSVP{EventID: eventID, PlayerID: playerID, ActorAccountID: actor.ID, Status: status, Source: source, UpdatedAt: s.Now()}
|
||||
if err := rsvp.Validate(); err != nil {
|
||||
return domain.RSVP{}, err
|
||||
}
|
||||
out, err := s.Store.UpsertRSVP(ctx, rsvp)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "rsvp.set", eventID, out)
|
||||
s.Bus.Publish("event:"+eventID, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
rsvps, err := s.Store.ListRSVPs(ctx, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
players, err := s.Store.ListPlayers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
going := map[string]bool{}
|
||||
for _, r := range rsvps {
|
||||
going[r.PlayerID] = r.Status == domain.Going
|
||||
}
|
||||
eligible := players[:0]
|
||||
for _, p := range players {
|
||||
if going[p.ID] {
|
||||
eligible = append(eligible, p)
|
||||
}
|
||||
}
|
||||
return domain.Balance(eventID, eligible)
|
||||
}
|
||||
|
||||
func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate) error {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
if err := s.Store.SaveTeams(ctx, eventID, candidate.Teams); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "balance.selected", eventID, candidate)
|
||||
s.Bus.Publish("event:"+eventID, candidate.Teams)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamID, playerID string) (domain.Team, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.Team{}, domain.ErrForbidden
|
||||
}
|
||||
team, err := s.Store.AssignCaptain(ctx, teamID, playerID)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "captain.assigned", teamID, map[string]string{"playerId": playerID})
|
||||
s.Bus.Publish("team:"+teamID, team)
|
||||
}
|
||||
return team, err
|
||||
}
|
||||
|
||||
func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool {
|
||||
return actor.IsAdmin() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID)
|
||||
}
|
||||
|
||||
func (s *Service) RecordMap(ctx context.Context, actor domain.Account, seriesID, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.Series{}, domain.ErrForbidden
|
||||
}
|
||||
series, err := s.Store.GetSeries(ctx, seriesID)
|
||||
if err != nil {
|
||||
return domain.Series{}, err
|
||||
}
|
||||
if series.Version != expectedVersion {
|
||||
return domain.Series{}, fmt.Errorf("%w: stale series version", domain.ErrConflict)
|
||||
}
|
||||
if err := series.RecordResult(domain.MapResult{MapName: mapName, Outcome: outcome, ActorAccountID: actor.ID, RecordedAt: s.Now()}); err != nil {
|
||||
return domain.Series{}, err
|
||||
}
|
||||
out, err := s.Store.SaveSeries(ctx, series)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "series.map_recorded", seriesID, out.Results[len(out.Results)-1])
|
||||
s.Bus.Publish("series:"+seriesID, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) CorrectMap(ctx context.Context, actor domain.Account, seriesID string, resultIndex int, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.Series{}, domain.ErrForbidden
|
||||
}
|
||||
series, err := s.Store.GetSeries(ctx, seriesID)
|
||||
if err != nil {
|
||||
return domain.Series{}, err
|
||||
}
|
||||
if series.Version != expectedVersion {
|
||||
return domain.Series{}, fmt.Errorf("%w: stale series version", domain.ErrConflict)
|
||||
}
|
||||
if err := series.CorrectResult(resultIndex, domain.MapResult{MapName: mapName, Outcome: outcome, ActorAccountID: actor.ID, RecordedAt: s.Now()}); err != nil {
|
||||
return domain.Series{}, err
|
||||
}
|
||||
out, err := s.Store.SaveSeries(ctx, series)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "series.map_corrected", seriesID, out.Results[len(out.Results)-1])
|
||||
s.Bus.Publish("series:"+seriesID, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
255
backend/internal/domain/balancer.go
Normal file
255
backend/internal/domain/balancer.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BalanceCandidate struct {
|
||||
Teams []Team `json:"teams"`
|
||||
Reserve []string `json:"reserve"`
|
||||
Score int `json:"score"`
|
||||
Explanation []string `json:"explanation"`
|
||||
}
|
||||
|
||||
// Balance deterministically creates three alternatives. Players are assigned to
|
||||
// their strongest role while preserving the 1/2/2 composition of every team.
|
||||
func Balance(eventID string, players []Player) ([]BalanceCandidate, error) {
|
||||
if len(players) < 10 {
|
||||
return nil, fmt.Errorf("%w: at least ten players are required", ErrInvalid)
|
||||
}
|
||||
base := slices.Clone(players)
|
||||
slices.SortFunc(base, func(a, b Player) int {
|
||||
if a.ID < b.ID {
|
||||
return -1
|
||||
}
|
||||
if a.ID > b.ID {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
roleOrders := [][]Role{
|
||||
{Tank, Damage, Support},
|
||||
{Tank, Support, Damage},
|
||||
{Damage, Tank, Support},
|
||||
{Damage, Support, Tank},
|
||||
{Support, Tank, Damage},
|
||||
{Support, Damage, Tank},
|
||||
}
|
||||
all := make([]BalanceCandidate, 0, len(roleOrders)*2)
|
||||
for orderIndex, order := range roleOrders {
|
||||
for offset := 0; offset < 2; offset++ {
|
||||
all = append(all, buildCandidate(eventID, base, orderIndex*2+offset, order, offset))
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(all, func(a, b BalanceCandidate) int { return a.Score - b.Score })
|
||||
result := make([]BalanceCandidate, 0, 3)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range all {
|
||||
signature := candidateSignature(candidate)
|
||||
if seen[signature] {
|
||||
continue
|
||||
}
|
||||
seen[signature] = true
|
||||
result = append(result, candidate)
|
||||
if len(result) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildCandidate(eventID string, players []Player, variant int, roleOrder []Role, offset int) BalanceCandidate {
|
||||
teamCount := len(players) / 5
|
||||
used := teamCount * 5
|
||||
c := BalanceCandidate{Reserve: make([]string, 0, len(players)-used)}
|
||||
for _, p := range players[used:] {
|
||||
c.Reserve = append(c.Reserve, p.ID)
|
||||
}
|
||||
active := slices.Clone(players[:used])
|
||||
for i := 0; i < teamCount; i++ {
|
||||
c.Teams = append(c.Teams, Team{
|
||||
ID: fmt.Sprintf("%s-v%d-team-%d", eventID, variant+1, i+1),
|
||||
EventID: eventID,
|
||||
Name: fmt.Sprintf("Team %d", i+1),
|
||||
})
|
||||
}
|
||||
for _, role := range roleOrder {
|
||||
slices.SortStableFunc(active, func(a, b Player) int {
|
||||
ar, br := rating(a, role), rating(b, role)
|
||||
if ar != br {
|
||||
return br - ar
|
||||
}
|
||||
if a.ID < b.ID {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
perTeam := 2
|
||||
if role == Tank {
|
||||
perTeam = 1
|
||||
}
|
||||
count := teamCount * perTeam
|
||||
selected := slices.Clone(active[:count])
|
||||
active = active[count:]
|
||||
for i, p := range selected {
|
||||
teamIndex := (i + offset) % teamCount
|
||||
if (i/teamCount)%2 == 1 {
|
||||
teamIndex = teamCount - 1 - teamIndex
|
||||
if teamIndex < 0 {
|
||||
teamIndex += teamCount
|
||||
}
|
||||
}
|
||||
c.Teams[teamIndex].Slots = append(c.Teams[teamIndex].Slots, Slot{PlayerID: p.ID, Role: role, Rating: rating(p, role)})
|
||||
}
|
||||
}
|
||||
optimizeCandidate(c.Teams, players)
|
||||
c.Score = balanceScore(c.Teams, players)
|
||||
preferredRoles, preferredTeammates := preferenceStats(c.Teams, players)
|
||||
c.Explanation = []string{
|
||||
fmt.Sprintf("%d preferred role assignments satisfied", preferredRoles),
|
||||
fmt.Sprintf("%d preferred teammate choices satisfied", preferredTeammates),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func balanceScore(teams []Team, players []Player) int {
|
||||
playerByID := make(map[string]Player, len(players))
|
||||
for _, player := range players {
|
||||
playerByID[player.ID] = player
|
||||
}
|
||||
minTotal, maxTotal := math.MaxInt, 0
|
||||
roleTotals := map[Role][]int{Tank: {}, Damage: {}, Support: {}}
|
||||
weakRolePenalty := 0
|
||||
preferredRolePenalty := 0
|
||||
teamByPlayer := make(map[string]int)
|
||||
for teamIndex, team := range teams {
|
||||
total := 0
|
||||
perRole := map[Role]int{}
|
||||
for _, slot := range team.Slots {
|
||||
total += slot.Rating
|
||||
perRole[slot.Role] += slot.Rating
|
||||
player := playerByID[slot.PlayerID]
|
||||
teamByPlayer[slot.PlayerID] = teamIndex
|
||||
strongest := max(player.Ratings.Tank, player.Ratings.Damage, player.Ratings.Support)
|
||||
weakRolePenalty += strongest - slot.Rating
|
||||
if len(player.PreferredRoles) > 0 && !slices.Contains(player.PreferredRoles, slot.Role) {
|
||||
preferredRolePenalty += 12
|
||||
}
|
||||
}
|
||||
minTotal = min(minTotal, total)
|
||||
maxTotal = max(maxTotal, total)
|
||||
for role := range roleTotals {
|
||||
roleTotals[role] = append(roleTotals[role], perRole[role])
|
||||
}
|
||||
}
|
||||
preferredTeammatePenalty := 0
|
||||
for _, player := range players {
|
||||
teamIndex, active := teamByPlayer[player.ID]
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
for _, preferredID := range player.PreferredPlayerIDs {
|
||||
if preferredTeam, preferredActive := teamByPlayer[preferredID]; preferredActive && preferredTeam != teamIndex {
|
||||
preferredTeammatePenalty += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty
|
||||
for _, totals := range roleTotals {
|
||||
minimum, maximum := math.MaxInt, 0
|
||||
for _, total := range totals {
|
||||
minimum = min(minimum, total)
|
||||
maximum = max(maximum, total)
|
||||
}
|
||||
score += (maximum - minimum) * 2
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func optimizeCandidate(teams []Team, players []Player) {
|
||||
for iteration := 0; iteration < 20; iteration++ {
|
||||
bestScore := balanceScore(teams, players)
|
||||
bestA, bestB, bestSlotA, bestSlotB := -1, -1, -1, -1
|
||||
for teamA := 0; teamA < len(teams); teamA++ {
|
||||
for teamB := teamA + 1; teamB < len(teams); teamB++ {
|
||||
for slotA := range teams[teamA].Slots {
|
||||
for slotB := range teams[teamB].Slots {
|
||||
if teams[teamA].Slots[slotA].Role != teams[teamB].Slots[slotB].Role {
|
||||
continue
|
||||
}
|
||||
teams[teamA].Slots[slotA], teams[teamB].Slots[slotB] = teams[teamB].Slots[slotB], teams[teamA].Slots[slotA]
|
||||
score := balanceScore(teams, players)
|
||||
teams[teamA].Slots[slotA], teams[teamB].Slots[slotB] = teams[teamB].Slots[slotB], teams[teamA].Slots[slotA]
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestA, bestB, bestSlotA, bestSlotB = teamA, teamB, slotA, slotB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestA < 0 {
|
||||
return
|
||||
}
|
||||
teams[bestA].Slots[bestSlotA], teams[bestB].Slots[bestSlotB] = teams[bestB].Slots[bestSlotB], teams[bestA].Slots[bestSlotA]
|
||||
}
|
||||
}
|
||||
|
||||
func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
playerByID := make(map[string]Player, len(players))
|
||||
teamByPlayer := make(map[string]int)
|
||||
roleByPlayer := make(map[string]Role)
|
||||
for _, player := range players {
|
||||
playerByID[player.ID] = player
|
||||
}
|
||||
for teamIndex, team := range teams {
|
||||
for _, slot := range team.Slots {
|
||||
teamByPlayer[slot.PlayerID] = teamIndex
|
||||
roleByPlayer[slot.PlayerID] = slot.Role
|
||||
}
|
||||
}
|
||||
roleMatches, teammateMatches := 0, 0
|
||||
for playerID, teamIndex := range teamByPlayer {
|
||||
player := playerByID[playerID]
|
||||
if len(player.PreferredRoles) > 0 && slices.Contains(player.PreferredRoles, roleByPlayer[playerID]) {
|
||||
roleMatches++
|
||||
}
|
||||
for _, preferredID := range player.PreferredPlayerIDs {
|
||||
if preferredTeam, ok := teamByPlayer[preferredID]; ok && preferredTeam == teamIndex {
|
||||
teammateMatches++
|
||||
}
|
||||
}
|
||||
}
|
||||
return roleMatches, teammateMatches
|
||||
}
|
||||
|
||||
func candidateSignature(candidate BalanceCandidate) string {
|
||||
parts := make([]string, 0, len(candidate.Teams))
|
||||
for _, team := range candidate.Teams {
|
||||
slots := slices.Clone(team.Slots)
|
||||
slices.SortFunc(slots, func(a, b Slot) int {
|
||||
return strings.Compare(string(a.Role)+a.PlayerID, string(b.Role)+b.PlayerID)
|
||||
})
|
||||
var values []string
|
||||
for _, slot := range slots {
|
||||
values = append(values, string(slot.Role)+":"+slot.PlayerID)
|
||||
}
|
||||
parts = append(parts, strings.Join(values, ","))
|
||||
}
|
||||
slices.Sort(parts)
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func rating(p Player, role Role) int {
|
||||
switch role {
|
||||
case Tank:
|
||||
return p.Ratings.Tank
|
||||
case Damage:
|
||||
return p.Ratings.Damage
|
||||
default:
|
||||
return p.Ratings.Support
|
||||
}
|
||||
}
|
||||
97
backend/internal/domain/balancer_test.go
Normal file
97
backend/internal/domain/balancer_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBalanceProducesDeterministicFullTeamsAndReserve(t *testing.T) {
|
||||
var players []Player
|
||||
for i := 0; i < 23; i++ {
|
||||
players = append(players, Player{
|
||||
ID: fmt.Sprintf("p%02d", i),
|
||||
Ratings: Ratings{
|
||||
Tank: 5 + i%36,
|
||||
Damage: 5 + (i*3)%36,
|
||||
Support: 5 + (i*7)%36,
|
||||
},
|
||||
})
|
||||
}
|
||||
first, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := Balance("event", players)
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatal("balancer output is not deterministic")
|
||||
}
|
||||
if len(first) != 3 {
|
||||
t.Fatalf("got %d candidates", len(first))
|
||||
}
|
||||
for _, candidate := range first {
|
||||
if len(candidate.Teams) != 4 || len(candidate.Reserve) != 3 {
|
||||
t.Fatalf("unexpected allocation: %d teams, %d reserve", len(candidate.Teams), len(candidate.Reserve))
|
||||
}
|
||||
for _, team := range candidate.Teams {
|
||||
counts := map[Role]int{}
|
||||
for _, slot := range team.Slots {
|
||||
counts[slot.Role]++
|
||||
}
|
||||
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
|
||||
t.Fatalf("invalid composition: %#v", counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceKeepsOddNumberOfFullTeams(t *testing.T) {
|
||||
players := make([]Player, 15)
|
||||
for i := range players {
|
||||
players[i] = Player{
|
||||
ID: fmt.Sprintf("p%02d", i),
|
||||
Ratings: Ratings{Tank: 10 + i, Damage: 15 + i%20, Support: 8 + (i*3)%30},
|
||||
}
|
||||
}
|
||||
candidates, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if len(candidate.Teams) != 3 || len(candidate.Reserve) != 0 {
|
||||
t.Fatalf("expected three full teams, got %d teams and %d reserve", len(candidate.Teams), len(candidate.Reserve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
players := make([]Player, 10)
|
||||
for i := range players {
|
||||
players[i] = Player{
|
||||
ID: fmt.Sprintf("p%02d", i),
|
||||
Ratings: Ratings{Tank: 20, Damage: 20, Support: 20},
|
||||
}
|
||||
}
|
||||
players[0].PreferredRoles = []Role{Support}
|
||||
players[0].PreferredPlayerIDs = []string{"p01"}
|
||||
candidates, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
best := candidates[0]
|
||||
var teamForP0, teamForP1 int = -1, -1
|
||||
var roleForP0 Role
|
||||
for teamIndex, team := range best.Teams {
|
||||
for _, slot := range team.Slots {
|
||||
switch slot.PlayerID {
|
||||
case "p00":
|
||||
teamForP0, roleForP0 = teamIndex, slot.Role
|
||||
case "p01":
|
||||
teamForP1 = teamIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
if teamForP0 != teamForP1 || roleForP0 != Support {
|
||||
t.Fatalf("preferences were not reflected in best candidate: role=%s teams=%d/%d", roleForP0, teamForP0, teamForP1)
|
||||
}
|
||||
}
|
||||
48
backend/internal/domain/draft_test.go
Normal file
48
backend/internal/domain/draft_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMapDraftAlternatesAndLeavesOneMap(t *testing.T) {
|
||||
d, err := NewMapDraft([]string{"A", "B", "C"}, "red", [2]string{"red", "blue"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("blue", "A", "actor", time.Now()); err == nil {
|
||||
t.Fatal("accepted out-of-turn ban")
|
||||
}
|
||||
if err := d.Ban("red", "A", "actor", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("blue", "B", "actor", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selected, ok := d.Selected(); !ok || selected != "C" {
|
||||
t.Fatalf("selected=%q ok=%v", selected, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroDraftRoleAndRepeatedBanRules(t *testing.T) {
|
||||
heroes := []Hero{{Name: "DVa", Role: Tank}, {Name: "Sigma", Role: Tank}, {Name: "Tracer", Role: Damage}, {Name: "Ana", Role: Support}}
|
||||
d, err := NewHeroDraft(heroes, [2]string{"a", "b"}, "a", 2, map[string][]string{"a": {"Ana"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("a", "Ana", "x", time.Now()); err == nil {
|
||||
t.Fatal("accepted team's repeated series ban")
|
||||
}
|
||||
if err := d.Ban("a", "DVa", "x", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("b", "Ana", "y", time.Now()); err != nil {
|
||||
t.Fatal("opponent should be allowed to ban Ana:", err)
|
||||
}
|
||||
if err := d.Ban("a", "Sigma", "x", time.Now()); err == nil {
|
||||
t.Fatal("accepted same-role bans by one team")
|
||||
}
|
||||
if err := d.Ban("a", "Tracer", "x", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
518
backend/internal/domain/model.go
Normal file
518
backend/internal/domain/model.go
Normal file
@@ -0,0 +1,518 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid input")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrDraftComplete = errors.New("draft is complete")
|
||||
)
|
||||
|
||||
type ID string
|
||||
type GlobalRole string
|
||||
|
||||
const (
|
||||
RolePlayer GlobalRole = "player"
|
||||
RoleAdmin GlobalRole = "admin"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
DiscordID string `json:"discordId"`
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Role GlobalRole `json:"role"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (a Account) IsAdmin() bool { return a.Role == RoleAdmin }
|
||||
|
||||
type Ratings struct {
|
||||
Tank int `json:"tank"`
|
||||
Damage int `json:"damage"`
|
||||
Support int `json:"support"`
|
||||
}
|
||||
|
||||
const (
|
||||
MinCompetitiveRank = 1 // Bronze 5
|
||||
MaxCompetitiveRank = 40 // Champion 1
|
||||
)
|
||||
|
||||
func (r Ratings) Validate() error {
|
||||
if r.Tank < MinCompetitiveRank || r.Tank > MaxCompetitiveRank ||
|
||||
r.Damage < MinCompetitiveRank || r.Damage > MaxCompetitiveRank ||
|
||||
r.Support < MinCompetitiveRank || r.Support > MaxCompetitiveRank {
|
||||
return fmt.Errorf("%w: ranks must be between Bronze 5 and Champion 1", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Player struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Ratings Ratings `json:"ratings"`
|
||||
PreferredRoles []Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (p Player) ValidatePreferences() error {
|
||||
if len(p.PreferredPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
||||
}
|
||||
seenRoles := map[Role]bool{}
|
||||
for _, role := range p.PreferredRoles {
|
||||
if (role != Tank && role != Damage && role != Support) || seenRoles[role] {
|
||||
return fmt.Errorf("%w: invalid preferred roles", ErrInvalid)
|
||||
}
|
||||
seenRoles[role] = true
|
||||
}
|
||||
seenPlayers := map[string]bool{}
|
||||
for _, playerID := range p.PreferredPlayerIDs {
|
||||
if playerID == "" || playerID == p.ID || seenPlayers[playerID] {
|
||||
return fmt.Errorf("%w: invalid preferred teammates", ErrInvalid)
|
||||
}
|
||||
seenPlayers[playerID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
RegistrationDeadline time.Time `json:"registrationDeadline"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (e Event) Validate() error {
|
||||
if strings.TrimSpace(e.Name) == "" || e.StartsAt.IsZero() || !e.EndsAt.After(e.StartsAt) ||
|
||||
e.RegistrationDeadline.IsZero() || e.RegistrationDeadline.After(e.StartsAt) {
|
||||
return fmt.Errorf("%w: event needs a name and a valid UTC interval", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RSVPStatus string
|
||||
|
||||
const (
|
||||
Going RSVPStatus = "Going"
|
||||
Maybe RSVPStatus = "Maybe"
|
||||
NotGoing RSVPStatus = "NotGoing"
|
||||
)
|
||||
|
||||
type RSVPSource string
|
||||
|
||||
const (
|
||||
SourcePlayer RSVPSource = "player"
|
||||
SourceAdmin RSVPSource = "admin"
|
||||
)
|
||||
|
||||
type RSVP struct {
|
||||
EventID string `json:"eventId"`
|
||||
PlayerID string `json:"playerId"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
Status RSVPStatus `json:"status"`
|
||||
Source RSVPSource `json:"source"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (r RSVP) Validate() error {
|
||||
if r.Status != Going && r.Status != Maybe && r.Status != NotGoing {
|
||||
return fmt.Errorf("%w: unknown RSVP status", ErrInvalid)
|
||||
}
|
||||
if r.EventID == "" || r.PlayerID == "" || r.ActorAccountID == "" {
|
||||
return fmt.Errorf("%w: RSVP identifiers are required", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
PlayerID string `json:"playerId"`
|
||||
Role Role `json:"role"`
|
||||
Rating int `json:"rating"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
CaptainPlayerID string `json:"captainPlayerId"`
|
||||
Slots []Slot `json:"slots"`
|
||||
}
|
||||
|
||||
func (t *Team) AssignCaptain(playerID string) error {
|
||||
if !slices.ContainsFunc(t.Slots, func(s Slot) bool { return s.PlayerID == playerID }) {
|
||||
return fmt.Errorf("%w: captain must be on the team", ErrInvalid)
|
||||
}
|
||||
t.CaptainPlayerID = playerID
|
||||
return nil
|
||||
}
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
Tank Role = "Tank"
|
||||
Damage Role = "Damage"
|
||||
Support Role = "Support"
|
||||
)
|
||||
|
||||
type Hero struct {
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type Ruleset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MapPools [][]string `json:"mapPools"`
|
||||
Heroes []Hero `json:"heroes"`
|
||||
HeroBansPerTeam int `json:"heroBansPerTeam"`
|
||||
BestOf int `json:"bestOf"`
|
||||
InitialMapBanner int `json:"initialMapBanner"`
|
||||
InitialHeroBanner int `json:"initialHeroBanner"`
|
||||
}
|
||||
|
||||
func (r Ruleset) Validate() error {
|
||||
if r.BestOf < 1 || r.BestOf%2 == 0 || r.HeroBansPerTeam < 0 || r.HeroBansPerTeam > 3 || len(r.MapPools) == 0 {
|
||||
return fmt.Errorf("%w: invalid ruleset", ErrInvalid)
|
||||
}
|
||||
for _, pool := range r.MapPools {
|
||||
if len(pool) == 0 {
|
||||
return fmt.Errorf("%w: empty map pool", ErrInvalid)
|
||||
}
|
||||
}
|
||||
for _, hero := range r.Heroes {
|
||||
if strings.TrimSpace(hero.Name) == "" || (hero.Role != Tank && hero.Role != Damage && hero.Role != Support) {
|
||||
return fmt.Errorf("%w: invalid hero catalog", ErrInvalid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CoinToss struct {
|
||||
Seed string `json:"seed"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
PerformedAt time.Time `json:"performedAt"`
|
||||
}
|
||||
|
||||
func TossCoin(teamA, teamB, seed string, now time.Time) (CoinToss, error) {
|
||||
if teamA == "" || teamB == "" || teamA == teamB || seed == "" {
|
||||
return CoinToss{}, fmt.Errorf("%w: two teams and a seed are required", ErrInvalid)
|
||||
}
|
||||
var n int64
|
||||
for _, r := range seed {
|
||||
n = n*31 + int64(r)
|
||||
}
|
||||
winner := teamA
|
||||
if rand.New(rand.NewSource(n)).Intn(2) == 1 { // deterministic and auditable
|
||||
winner = teamB
|
||||
}
|
||||
return CoinToss{Seed: seed, WinnerTeamID: winner, PerformedAt: now}, nil
|
||||
}
|
||||
|
||||
type DraftAction struct {
|
||||
TeamID string `json:"teamId"`
|
||||
Value string `json:"value"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type MapDraft struct {
|
||||
Pool []string `json:"pool"`
|
||||
Banned []string `json:"banned"`
|
||||
FirstTeamID string `json:"firstTeamId"`
|
||||
TeamIDs [2]string `json:"teamIds"`
|
||||
Actions []DraftAction `json:"actions"`
|
||||
}
|
||||
|
||||
func NewMapDraft(pool []string, firstTeam string, teams [2]string) (*MapDraft, error) {
|
||||
if len(pool) < 1 || teams[0] == teams[1] || (firstTeam != teams[0] && firstTeam != teams[1]) {
|
||||
return nil, fmt.Errorf("%w: invalid map draft", ErrInvalid)
|
||||
}
|
||||
return &MapDraft{Pool: slices.Clone(pool), FirstTeamID: firstTeam, TeamIDs: teams}, nil
|
||||
}
|
||||
|
||||
func (d *MapDraft) NextTeam() string {
|
||||
if len(d.Actions)%2 == 0 {
|
||||
return d.FirstTeamID
|
||||
}
|
||||
if d.FirstTeamID == d.TeamIDs[0] {
|
||||
return d.TeamIDs[1]
|
||||
}
|
||||
return d.TeamIDs[0]
|
||||
}
|
||||
|
||||
func (d *MapDraft) Ban(teamID, name, actor string, at time.Time) error {
|
||||
if len(d.Pool)-len(d.Banned) <= 1 {
|
||||
return ErrDraftComplete
|
||||
}
|
||||
if teamID != d.NextTeam() {
|
||||
return fmt.Errorf("%w: wrong team turn", ErrConflict)
|
||||
}
|
||||
if !slices.Contains(d.Pool, name) || slices.Contains(d.Banned, name) {
|
||||
return fmt.Errorf("%w: map unavailable", ErrInvalid)
|
||||
}
|
||||
d.Banned = append(d.Banned, name)
|
||||
d.Actions = append(d.Actions, DraftAction{TeamID: teamID, Value: name, ActorAccountID: actor, At: at})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *MapDraft) Selected() (string, bool) {
|
||||
if len(d.Pool)-len(d.Banned) != 1 {
|
||||
return "", false
|
||||
}
|
||||
for _, m := range d.Pool {
|
||||
if !slices.Contains(d.Banned, m) {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
type HeroDraft struct {
|
||||
Heroes []Hero `json:"heroes"`
|
||||
TeamIDs [2]string `json:"teamIds"`
|
||||
FirstTeamID string `json:"firstTeamId"`
|
||||
BansPerTeam int `json:"bansPerTeam"`
|
||||
SeriesBans map[string][]string `json:"seriesBans"`
|
||||
CurrentBans []DraftAction `json:"currentBans"`
|
||||
CurrentRoles map[string][]Role `json:"currentRoles"`
|
||||
}
|
||||
|
||||
func NewHeroDraft(heroes []Hero, teams [2]string, first string, count int, previous map[string][]string) (*HeroDraft, error) {
|
||||
if teams[0] == teams[1] || (first != teams[0] && first != teams[1]) || count < 0 {
|
||||
return nil, fmt.Errorf("%w: invalid hero draft", ErrInvalid)
|
||||
}
|
||||
if previous == nil {
|
||||
previous = map[string][]string{}
|
||||
}
|
||||
return &HeroDraft{Heroes: slices.Clone(heroes), TeamIDs: teams, FirstTeamID: first, BansPerTeam: count, SeriesBans: previous, CurrentRoles: map[string][]Role{}}, nil
|
||||
}
|
||||
|
||||
func (d *HeroDraft) NextTeam() string {
|
||||
if len(d.CurrentBans)%2 == 0 {
|
||||
return d.FirstTeamID
|
||||
}
|
||||
if d.FirstTeamID == d.TeamIDs[0] {
|
||||
return d.TeamIDs[1]
|
||||
}
|
||||
return d.TeamIDs[0]
|
||||
}
|
||||
|
||||
func (d *HeroDraft) Complete() bool { return len(d.CurrentBans) >= d.BansPerTeam*2 }
|
||||
|
||||
func (d *HeroDraft) Ban(teamID, heroName, actor string, at time.Time) error {
|
||||
if d.Complete() {
|
||||
return ErrDraftComplete
|
||||
}
|
||||
if teamID != d.NextTeam() {
|
||||
return fmt.Errorf("%w: wrong team turn", ErrConflict)
|
||||
}
|
||||
var hero *Hero
|
||||
for i := range d.Heroes {
|
||||
if d.Heroes[i].Name == heroName {
|
||||
hero = &d.Heroes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hero == nil || slices.ContainsFunc(d.CurrentBans, func(a DraftAction) bool { return a.Value == heroName }) {
|
||||
return fmt.Errorf("%w: hero unavailable", ErrInvalid)
|
||||
}
|
||||
if slices.Contains(d.SeriesBans[teamID], heroName) {
|
||||
return fmt.Errorf("%w: team cannot repeat its own series ban", ErrInvalid)
|
||||
}
|
||||
if slices.Contains(d.CurrentRoles[teamID], hero.Role) {
|
||||
return fmt.Errorf("%w: a team's bans must have different roles", ErrInvalid)
|
||||
}
|
||||
d.CurrentBans = append(d.CurrentBans, DraftAction{TeamID: teamID, Value: heroName, ActorAccountID: actor, At: at})
|
||||
d.CurrentRoles[teamID] = append(d.CurrentRoles[teamID], hero.Role)
|
||||
d.SeriesBans[teamID] = append(d.SeriesBans[teamID], heroName)
|
||||
return nil
|
||||
}
|
||||
|
||||
type MapOutcome string
|
||||
|
||||
const (
|
||||
TeamAWin MapOutcome = "TeamAWin"
|
||||
TeamBWin MapOutcome = "TeamBWin"
|
||||
Draw MapOutcome = "Draw"
|
||||
)
|
||||
|
||||
type MapResult struct {
|
||||
MapName string `json:"mapName"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
Outcome MapOutcome `json:"outcome"`
|
||||
RecordedAt time.Time `json:"recordedAt"`
|
||||
CorrectionOf int `json:"correctionOf,omitempty"`
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID string `json:"id"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
TeamAID string `json:"teamAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
BestOf int `json:"bestOf"`
|
||||
Results []MapResult `json:"results"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Series) RecordResult(result MapResult) error {
|
||||
if s.WinnerTeamID != "" {
|
||||
return fmt.Errorf("%w: series already complete", ErrConflict)
|
||||
}
|
||||
if err := validateMapResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
result.CorrectionOf = 0
|
||||
s.Results = append(s.Results, result)
|
||||
s.Version++
|
||||
s.recalculateWinner()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) CorrectResult(index int, result MapResult) error {
|
||||
if index < 0 || index >= len(s.Results) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := validateMapResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
result.CorrectionOf = index + 1
|
||||
s.Results = append(s.Results, result)
|
||||
s.Version++
|
||||
s.recalculateWinner()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMapResult(result MapResult) error {
|
||||
if result.MapName == "" || (result.Outcome != TeamAWin && result.Outcome != TeamBWin && result.Outcome != Draw) {
|
||||
return fmt.Errorf("%w: invalid map result", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) recalculateWinner() {
|
||||
s.WinnerTeamID = ""
|
||||
need := s.BestOf/2 + 1
|
||||
a, b := 0, 0
|
||||
superseded := make(map[int]bool)
|
||||
for _, result := range s.Results {
|
||||
if result.CorrectionOf > 0 {
|
||||
superseded[result.CorrectionOf] = true
|
||||
}
|
||||
}
|
||||
for i, r := range s.Results {
|
||||
if superseded[i+1] {
|
||||
continue
|
||||
}
|
||||
if r.Outcome == TeamAWin {
|
||||
a++
|
||||
} else if r.Outcome == TeamBWin {
|
||||
b++
|
||||
}
|
||||
}
|
||||
if a >= need {
|
||||
s.WinnerTeamID = s.TeamAID
|
||||
} else if b >= need {
|
||||
s.WinnerTeamID = s.TeamBID
|
||||
}
|
||||
}
|
||||
|
||||
type Tournament struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Rounds [][]Series `json:"rounds"`
|
||||
}
|
||||
|
||||
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {
|
||||
if len(teams) < 2 {
|
||||
return nil, fmt.Errorf("%w: tournament needs at least two teams", ErrInvalid)
|
||||
}
|
||||
t := &Tournament{ID: id, EventID: eventID, Name: name, TeamIDs: slices.Clone(teams)}
|
||||
bracketSize := 1
|
||||
for bracketSize < len(teams) {
|
||||
bracketSize *= 2
|
||||
}
|
||||
firstMatchCount := bracketSize / 2
|
||||
first := make([]Series, firstMatchCount)
|
||||
for i := range first {
|
||||
first[i] = Series{ID: fmt.Sprintf("%s-r1-m%d", id, i+1), TournamentID: id, TeamAID: teams[i], BestOf: 3}
|
||||
}
|
||||
for i := firstMatchCount; i < len(teams); i++ {
|
||||
first[i-firstMatchCount].TeamBID = teams[i]
|
||||
}
|
||||
for i := range first {
|
||||
if first[i].TeamBID == "" {
|
||||
first[i].WinnerTeamID = first[i].TeamAID
|
||||
}
|
||||
}
|
||||
t.Rounds = append(t.Rounds, first)
|
||||
for matches, round := firstMatchCount/2, 2; matches >= 1; matches, round = matches/2, round+1 {
|
||||
next := make([]Series, matches)
|
||||
for i := range next {
|
||||
next[i] = Series{ID: fmt.Sprintf("%s-r%d-m%d", id, round, i+1), TournamentID: id, BestOf: 3}
|
||||
}
|
||||
t.Rounds = append(t.Rounds, next)
|
||||
}
|
||||
for match := range first {
|
||||
if first[match].WinnerTeamID == "" {
|
||||
continue
|
||||
}
|
||||
target := &t.Rounds[1][match/2]
|
||||
if match%2 == 0 {
|
||||
target.TeamAID = first[match].WinnerTeamID
|
||||
} else {
|
||||
target.TeamBID = first[match].WinnerTeamID
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *Tournament) Advance(round, match int) error {
|
||||
if round < 0 || round >= len(t.Rounds) || match < 0 || match >= len(t.Rounds[round]) {
|
||||
return ErrNotFound
|
||||
}
|
||||
s := t.Rounds[round][match]
|
||||
if s.WinnerTeamID == "" {
|
||||
return fmt.Errorf("%w: series is not complete", ErrConflict)
|
||||
}
|
||||
if len(t.Rounds[round]) == 1 {
|
||||
t.WinnerTeamID = s.WinnerTeamID
|
||||
return nil
|
||||
}
|
||||
if len(t.Rounds) == round+1 {
|
||||
next := make([]Series, len(t.Rounds[round])/2)
|
||||
for i := range next {
|
||||
next[i] = Series{ID: fmt.Sprintf("%s-r%d-m%d", t.ID, round+2, i+1), TournamentID: t.ID, BestOf: 3}
|
||||
}
|
||||
t.Rounds = append(t.Rounds, next)
|
||||
}
|
||||
target := &t.Rounds[round+1][match/2]
|
||||
if match%2 == 0 {
|
||||
target.TeamAID = s.WinnerTeamID
|
||||
} else {
|
||||
target.TeamBID = s.WinnerTeamID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
backend/internal/domain/model_test.go
Normal file
60
backend/internal/domain/model_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRatingsAndCaptainInvariants(t *testing.T) {
|
||||
if err := (Ratings{Tank: 1, Damage: 40, Support: 20}).Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (Ratings{Tank: 0, Damage: 20, Support: 20}).Validate(); err == nil {
|
||||
t.Fatal("expected invalid rating")
|
||||
}
|
||||
team := Team{Slots: []Slot{{PlayerID: "p1", Role: Tank}}}
|
||||
if err := team.AssignCaptain("outsider"); err == nil {
|
||||
t.Fatal("outsider was assigned captain")
|
||||
}
|
||||
if err := team.AssignCaptain("p1"); err != nil || team.CaptainPlayerID != "p1" {
|
||||
t.Fatalf("valid captain rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerPreferenceLimits(t *testing.T) {
|
||||
player := Player{ID: "p1", PreferredRoles: []Role{Tank, Support}, PreferredPlayerIDs: []string{"p2", "p3", "p4"}}
|
||||
if err := player.ValidatePreferences(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player.PreferredPlayerIDs = append(player.PreferredPlayerIDs, "p5")
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected teammate preference limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesBestOfThree(t *testing.T) {
|
||||
s := Series{TeamAID: "a", TeamBID: "b", BestOf: 3}
|
||||
for _, outcome := range []MapOutcome{TeamAWin, Draw, TeamAWin} {
|
||||
if err := s.RecordResult(MapResult{MapName: "map", Outcome: outcome}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.WinnerTeamID != "a" || s.Version != 3 {
|
||||
t.Fatalf("unexpected completed series: %+v", s)
|
||||
}
|
||||
if err := s.RecordResult(MapResult{MapName: "late", Outcome: TeamBWin}); err == nil {
|
||||
t.Fatal("accepted result after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesResultCorrectionPreservesHistoryAndRecalculatesWinner(t *testing.T) {
|
||||
s := Series{TeamAID: "a", TeamBID: "b", BestOf: 3}
|
||||
_ = s.RecordResult(MapResult{MapName: "one", Outcome: TeamAWin})
|
||||
_ = s.RecordResult(MapResult{MapName: "two", Outcome: TeamAWin})
|
||||
if s.WinnerTeamID != "a" {
|
||||
t.Fatal("series should initially be won by team A")
|
||||
}
|
||||
if err := s.CorrectResult(1, MapResult{MapName: "two", Outcome: TeamBWin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.WinnerTeamID != "" || len(s.Results) != 3 || s.Results[2].CorrectionOf != 2 {
|
||||
t.Fatalf("unexpected corrected series: %+v", s)
|
||||
}
|
||||
}
|
||||
45
backend/internal/domain/tournament_test.go
Normal file
45
backend/internal/domain/tournament_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSingleEliminationAdvancement(t *testing.T) {
|
||||
tr, err := NewTournament("cup", "event", "Night Cup", []string{"a", "b", "c", "d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Rounds[0][0].WinnerTeamID = "a"
|
||||
if err := tr.Advance(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Rounds[0][1].WinnerTeamID = "d"
|
||||
if err := tr.Advance(0, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tr.Rounds[1][0]; got.TeamAID != "a" || got.TeamBID != "d" {
|
||||
t.Fatalf("unexpected final: %+v", got)
|
||||
}
|
||||
tr.Rounds[1][0].WinnerTeamID = "d"
|
||||
if err := tr.Advance(1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tr.WinnerTeamID != "d" {
|
||||
t.Fatalf("winner = %q", tr.WinnerTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTournamentCreatesByeForNonPowerOfTwo(t *testing.T) {
|
||||
tr, err := NewTournament("cup", "event", "Cup", []string{"a", "b", "c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tr.Rounds) != 2 || len(tr.Rounds[0]) != 2 {
|
||||
t.Fatalf("unexpected bracket shape: %+v", tr.Rounds)
|
||||
}
|
||||
bye := tr.Rounds[0][1]
|
||||
if bye.TeamAID != "b" || bye.TeamBID != "" || bye.WinnerTeamID != "b" {
|
||||
t.Fatalf("unexpected bye: %+v", bye)
|
||||
}
|
||||
if tr.Rounds[1][0].TeamBID != "b" {
|
||||
t.Fatalf("bye was not advanced: %+v", tr.Rounds[1][0])
|
||||
}
|
||||
}
|
||||
51
backend/internal/realtime/hub.go
Normal file
51
backend/internal/realtime/hub.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Topic string `json:"topic"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subs map[string]map[chan Event]struct{}
|
||||
}
|
||||
|
||||
func New() *Hub { return &Hub{subs: make(map[string]map[chan Event]struct{})} }
|
||||
|
||||
func (h *Hub) Publish(topic string, value any) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, key := range []string{"*", topic} {
|
||||
for ch := range h.subs[key] {
|
||||
select {
|
||||
case ch <- Event{Topic: topic, Data: data}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Subscribe(topic string) (<-chan Event, func()) {
|
||||
ch := make(chan Event, 16)
|
||||
h.mu.Lock()
|
||||
if h.subs[topic] == nil {
|
||||
h.subs[topic] = make(map[chan Event]struct{})
|
||||
}
|
||||
h.subs[topic][ch] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
delete(h.subs[topic], ch)
|
||||
close(ch)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
108
backend/migrations/001_initial.sql
Normal file
108
backend/migrations/001_initial.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- +mixmaker Up
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE accounts (
|
||||
id text PRIMARY KEY,
|
||||
discord_id text NOT NULL UNIQUE,
|
||||
username text NOT NULL,
|
||||
avatar_url text NOT NULL DEFAULT '',
|
||||
role text NOT NULL CHECK (role IN ('admin', 'player')),
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE players (
|
||||
id text PRIMARY KEY,
|
||||
account_id text NOT NULL UNIQUE REFERENCES accounts(id),
|
||||
display_name text NOT NULL,
|
||||
tank_rating smallint NOT NULL CHECK (tank_rating BETWEEN 1 AND 40),
|
||||
damage_rating smallint NOT NULL CHECK (damage_rating BETWEEN 1 AND 40),
|
||||
support_rating smallint NOT NULL CHECK (support_rating BETWEEN 1 AND 40),
|
||||
preferred_roles jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
preferred_player_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token_hash text PRIMARY KEY,
|
||||
account_id text NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX sessions_expiry_idx ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE events (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz NOT NULL,
|
||||
registration_deadline timestamptz NOT NULL,
|
||||
created_by text NOT NULL REFERENCES accounts(id),
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CHECK (ends_at > starts_at),
|
||||
CHECK (registration_deadline <= starts_at)
|
||||
);
|
||||
|
||||
CREATE TABLE rsvps (
|
||||
event_id text NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
player_id text NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
status text NOT NULL CHECK (status IN ('Going', 'Maybe', 'NotGoing')),
|
||||
actor_account_id text NOT NULL REFERENCES accounts(id),
|
||||
source text NOT NULL CHECK (source IN ('player', 'admin')),
|
||||
updated_at timestamptz NOT NULL,
|
||||
PRIMARY KEY(event_id, player_id)
|
||||
);
|
||||
|
||||
CREATE TABLE teams (
|
||||
id text PRIMARY KEY,
|
||||
event_id text NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
captain_player_id text REFERENCES players(id),
|
||||
slots jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE rulesets (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
body jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE series (
|
||||
id text PRIMARY KEY,
|
||||
tournament_id text NOT NULL DEFAULT '',
|
||||
body jsonb NOT NULL,
|
||||
version integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE tournaments (
|
||||
id text PRIMARY KEY,
|
||||
event_id text NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
body jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE draft_states (
|
||||
id text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('map', 'hero', 'coin')),
|
||||
body jsonb NOT NULL,
|
||||
version integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
actor_account_id text NOT NULL REFERENCES accounts(id),
|
||||
action text NOT NULL,
|
||||
subject_id text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX audit_subject_idx ON audit_log(subject_id, created_at);
|
||||
|
||||
-- +mixmaker Down
|
||||
DROP TABLE IF EXISTS audit_log, draft_states, tournaments, series, rulesets, teams,
|
||||
rsvps, events, sessions, players, accounts, schema_migrations CASCADE;
|
||||
52
backend/migrations/002_seed_rulesets.sql
Normal file
52
backend/migrations/002_seed_rulesets.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- +mixmaker Up
|
||||
INSERT INTO rulesets (id, name, body)
|
||||
VALUES
|
||||
(
|
||||
'standard-control-hybrid-control',
|
||||
'Control → Hybrid → Control',
|
||||
'{
|
||||
"ID": "standard-control-hybrid-control",
|
||||
"Name": "Control → Hybrid → Control",
|
||||
"MapPools": [
|
||||
["Antarctic Peninsula", "Busan", "Ilios", "Lijiang Tower", "Nepal", "Oasis", "Samoa"],
|
||||
["Blizzard World", "Eichenwalde", "Hollywood", "King''s Row", "Midtown", "Numbani", "Paraíso"],
|
||||
["Antarctic Peninsula", "Busan", "Ilios", "Lijiang Tower", "Nepal", "Oasis", "Samoa"]
|
||||
],
|
||||
"Heroes": [
|
||||
{"name":"D.Va","role":"Tank"},{"name":"Doomfist","role":"Tank"},{"name":"Hazard","role":"Tank"},{"name":"Junker Queen","role":"Tank"},{"name":"Mauga","role":"Tank"},{"name":"Orisa","role":"Tank"},{"name":"Ramattra","role":"Tank"},{"name":"Reinhardt","role":"Tank"},{"name":"Roadhog","role":"Tank"},{"name":"Sigma","role":"Tank"},{"name":"Winston","role":"Tank"},{"name":"Wrecking Ball","role":"Tank"},{"name":"Zarya","role":"Tank"},
|
||||
{"name":"Ashe","role":"Damage"},{"name":"Bastion","role":"Damage"},{"name":"Cassidy","role":"Damage"},{"name":"Echo","role":"Damage"},{"name":"Freja","role":"Damage"},{"name":"Genji","role":"Damage"},{"name":"Hanzo","role":"Damage"},{"name":"Junkrat","role":"Damage"},{"name":"Mei","role":"Damage"},{"name":"Pharah","role":"Damage"},{"name":"Reaper","role":"Damage"},{"name":"Sojourn","role":"Damage"},{"name":"Soldier: 76","role":"Damage"},{"name":"Sombra","role":"Damage"},{"name":"Symmetra","role":"Damage"},{"name":"Torbjörn","role":"Damage"},{"name":"Tracer","role":"Damage"},{"name":"Venture","role":"Damage"},{"name":"Widowmaker","role":"Damage"},
|
||||
{"name":"Ana","role":"Support"},{"name":"Baptiste","role":"Support"},{"name":"Brigitte","role":"Support"},{"name":"Illari","role":"Support"},{"name":"Juno","role":"Support"},{"name":"Kiriko","role":"Support"},{"name":"Lifeweaver","role":"Support"},{"name":"Lúcio","role":"Support"},{"name":"Mercy","role":"Support"},{"name":"Moira","role":"Support"},{"name":"Zenyatta","role":"Support"}
|
||||
],
|
||||
"HeroBansPerTeam": 2,
|
||||
"BestOf": 3,
|
||||
"InitialMapBanner": 0,
|
||||
"InitialHeroBanner": 1
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO rulesets (id, name, body)
|
||||
VALUES (
|
||||
'variety-control-push-escort',
|
||||
'Control → Push → Escort',
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
(SELECT body FROM rulesets WHERE id = 'standard-control-hybrid-control'),
|
||||
'{ID}',
|
||||
'"variety-control-push-escort"'
|
||||
),
|
||||
'{Name}',
|
||||
'"Control → Push → Escort"'
|
||||
) || '{
|
||||
"MapPools": [
|
||||
["Antarctic Peninsula", "Busan", "Ilios", "Lijiang Tower", "Nepal", "Oasis", "Samoa"],
|
||||
["Colosseo", "Esperança", "New Queen Street", "Runasapi"],
|
||||
["Circuit Royal", "Dorado", "Havana", "Junkertown", "Rialto", "Route 66", "Shambali Monastery", "Watchpoint: Gibraltar"]
|
||||
]
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +mixmaker Down
|
||||
DELETE FROM rulesets
|
||||
WHERE id IN ('standard-control-hybrid-control', 'variety-control-push-escort');
|
||||
9
backend/migrations/003_player_preferences.sql
Normal file
9
backend/migrations/003_player_preferences.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- +mixmaker Up
|
||||
ALTER TABLE players
|
||||
ADD COLUMN IF NOT EXISTS preferred_roles jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS preferred_player_ids jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
-- +mixmaker Down
|
||||
ALTER TABLE players
|
||||
DROP COLUMN IF EXISTS preferred_player_ids,
|
||||
DROP COLUMN IF EXISTS preferred_roles;
|
||||
22
backend/migrations/004_overwatch_ranks.sql
Normal file
22
backend/migrations/004_overwatch_ranks.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- +mixmaker Up
|
||||
UPDATE players
|
||||
SET tank_rating = LEAST(GREATEST(tank_rating, 1), 40),
|
||||
damage_rating = LEAST(GREATEST(damage_rating, 1), 40),
|
||||
support_rating = LEAST(GREATEST(support_rating, 1), 40);
|
||||
|
||||
ALTER TABLE players
|
||||
DROP CONSTRAINT IF EXISTS players_tank_rating_check,
|
||||
DROP CONSTRAINT IF EXISTS players_damage_rating_check,
|
||||
DROP CONSTRAINT IF EXISTS players_support_rating_check,
|
||||
ADD CONSTRAINT players_tank_rating_check CHECK (tank_rating BETWEEN 1 AND 40),
|
||||
ADD CONSTRAINT players_damage_rating_check CHECK (damage_rating BETWEEN 1 AND 40),
|
||||
ADD CONSTRAINT players_support_rating_check CHECK (support_rating BETWEEN 1 AND 40);
|
||||
|
||||
-- +mixmaker Down
|
||||
ALTER TABLE players
|
||||
DROP CONSTRAINT IF EXISTS players_tank_rating_check,
|
||||
DROP CONSTRAINT IF EXISTS players_damage_rating_check,
|
||||
DROP CONSTRAINT IF EXISTS players_support_rating_check,
|
||||
ADD CONSTRAINT players_tank_rating_check CHECK (tank_rating BETWEEN 1 AND 100),
|
||||
ADD CONSTRAINT players_damage_rating_check CHECK (damage_rating BETWEEN 1 AND 100),
|
||||
ADD CONSTRAINT players_support_rating_check CHECK (support_rating BETWEEN 1 AND 100);
|
||||
Reference in New Issue
Block a user