Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 00:17:31 +03:00
parent d401e9009c
commit 1239fcee08
69 changed files with 12391 additions and 10 deletions

16
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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=

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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