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

View File

@@ -8,12 +8,14 @@ alwaysApply: true
- Mixmaker is a web app for organizing Overwatch scrims for communities of 20+ players. - Mixmaker is a web app for organizing Overwatch scrims for communities of 20+ players.
- Read the relevant files in `memory_bank/` before meaningful product or architecture changes. - Read the relevant files in `memory_bank/` before meaningful product or architecture changes.
- The core flows are Discord auth, player profiles, event calendar and RSVP, role-based team balancing, coin toss, map and hero bans, Bo3 results, and tournament brackets. - The core flows are Discord auth, player profiles, event calendar and RSVP, role-based team balancing, coin toss, map and hero bans, Bo3 results, and tournament brackets.
- Players sign in through Discord and may edit only their own Tank, Damage, and Support ratings. - Players sign in through Discord and may edit only their own Tank, Damage, and Support Overwatch ranks (Bronze 5 through Champion 1), preferred roles, and up to three preferred teammates.
- Scheduled events support Going, Maybe, and NotGoing participation statuses. - Scheduled events support Going, Maybe, and NotGoing participation statuses.
- Admins may manage events and change RSVP for another player; store the acting account for every administrative override. - Admins may manage events and change RSVP for another player; store the acting account for every administrative override.
- The balancer creates full 5v5 teams with 1 Tank, 2 Damage, and 2 Support; unmatched players remain in reserve. - The balancer creates full 5v5 teams with 1 Tank, 2 Damage, and 2 Support; unmatched players remain in reserve.
- Rating balance and 1/2/2 are hard priorities; role and teammate preferences are soft weighted objectives.
- An Admin assigns one captain from each team's current roster; captain permission is scoped to that team. - An Admin assigns one captain from each team's current roster; captain permission is scoped to that team.
- Keep the UI polished, responsive, mobile-first, and styled as a dark esports control panel. - Keep the UI polished, responsive, mobile-first, and styled as a dark esports control panel.
- Keep all user-visible strings available in Russian and English through the shared localization layer.
- Business rules and draft state are enforced by the backend; the frontend must not be the source of truth. - Business rules and draft state are enforced by the backend; the frontend must not be the source of truth.
- Preserve draft, result corrections, and match history as auditable actions instead of silently rewriting completed steps. - Preserve draft, result corrections, and match history as auditable actions instead of silently rewriting completed steps.
- Production deploys to Coolify as one Docker Compose stack with PostgreSQL; do not declare custom Compose networks unless explicitly required. - Production deploys to Coolify as one Docker Compose stack with PostgreSQL; do not declare custom Compose networks unless explicitly required.

14
.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[*.{ts,tsx,js,json,yaml,yml,md,css}]
indent_style = space
indent_size = 2

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Application
APP_ENV=development
HTTP_ADDR=:8080
PUBLIC_URL=http://localhost:5173
FRONTEND_URL=http://localhost:5173
LOG_LEVEL=info
# PostgreSQL
POSTGRES_DB=mixmaker
POSTGRES_USER=mixmaker
POSTGRES_PASSWORD=mixmaker
DATABASE_URL=postgres://mixmaker:mixmaker@localhost:5432/mixmaker?sslmode=disable
# Authentication
SESSION_TTL=168h
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URL=http://localhost:5173/api/auth/discord/callback
ADMIN_DISCORD_IDS=
# Browser-facing build configuration
VITE_API_BASE_URL=/api

51
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- run: go test -race ./...
- run: go vet ./...
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
compose:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: >
DISCORD_CLIENT_ID=test
DISCORD_CLIENT_SECRET=test
docker compose --env-file .env.example config --quiet

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
.env
.env.*
!.env.example
# Go
backend/bin/
backend/coverage.out
# Node
frontend/node_modules/
frontend/dist/
frontend/coverage/
# IDE and OS
.idea/
.vscode/
.DS_Store
# Local data
.data/
*.log

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
.PHONY: dev-db dev-db-down migrate backend frontend test lint compose-check
dev-db:
docker compose -f compose.dev.yaml up -d db
dev-db-down:
docker compose -f compose.dev.yaml down
migrate:
set -a; . ./.env; set +a; cd backend && go run ./cmd/api migrate
backend:
set -a; . ./.env; set +a; cd backend && go run ./cmd/api
frontend:
cd frontend && npm run dev
test:
cd backend && go test ./...
cd frontend && npm test
lint:
cd backend && go vet ./...
cd frontend && npm run lint
cd frontend && npm run typecheck
compose-check:
DISCORD_CLIENT_ID=test DISCORD_CLIENT_SECRET=test docker compose --env-file .env.example config --quiet

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Mixmaker
Mixmaker is a full-stack application for organizing Overwatch community scrims: Discord authentication, role ratings and preferences, RU/EN UI, event RSVP, preference-aware balanced 1/2/2 teams, captain assignment, Bo3 drafts and results, and a single-elimination bracket.
## Stack
- Go backend with Clean Architecture
- PostgreSQL
- React, TypeScript, Vite, TanStack Query and Router
- REST + Server-Sent Events
- Docker Compose for local PostgreSQL and Coolify production deployment
## Local development
Requirements: Go, Node.js/npm, Docker with Compose.
```bash
cp .env.example .env
make dev-db
make migrate
# Terminal 1
make backend
# Terminal 2
make frontend
```
The frontend runs at `http://localhost:5173`; API requests are proxied to the backend. To use the real API locally, copy `frontend/.env.example` to `frontend/.env` and set `VITE_DEMO_MODE=false`.
Create a Discord application, add the callback URL from `DISCORD_REDIRECT_URL`, and fill in `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, and `ADMIN_DISCORD_IDS`.
## Checks
```bash
make test
make lint
make compose-check
```
## Production
The root `compose.yaml` is designed for a single Coolify Docker Compose resource. Coolify owns routing and networking; the file intentionally does not declare custom networks or publish PostgreSQL.
See [deployments/coolify.md](deployments/coolify.md) for setup, secrets, storage, and backup requirements.
## Product documentation
Current decisions and domain rules live in [`memory_bank/`](memory_bank/).

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

19
compose.dev.yaml Normal file
View File

@@ -0,0 +1,19 @@
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: mixmaker
POSTGRES_USER: mixmaker
POSTGRES_PASSWORD: mixmaker
ports:
- "5432:5432"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mixmaker -d mixmaker"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_dev_data:

75
compose.yaml Normal file
View File

@@ -0,0 +1,75 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-mixmaker}
POSTGRES_USER: ${POSTGRES_USER:-mixmaker}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
migrations:
build:
context: ./backend
restart: "no"
command: ["migrate", "up"]
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
depends_on:
db:
condition: service_healthy
api:
build:
context: ./backend
restart: unless-stopped
environment:
APP_ENV: production
HTTP_ADDR: :8080
FRONTEND_URL: ${PUBLIC_URL:?PUBLIC_URL is required}
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
SESSION_TTL: ${SESSION_TTL:-168h}
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?DISCORD_CLIENT_ID is required}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?DISCORD_CLIENT_SECRET is required}
DISCORD_REDIRECT_URL: ${DISCORD_REDIRECT_URL:?DISCORD_REDIRECT_URL is required}
ADMIN_DISCORD_IDS: ${ADMIN_DISCORD_IDS:-}
LOG_LEVEL: ${LOG_LEVEL:-info}
depends_on:
migrations:
condition: service_completed_successfully
expose:
- "8080"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
web:
build:
context: ./frontend
args:
VITE_API_BASE_URL: /api
restart: unless-stopped
depends_on:
api:
condition: service_healthy
expose:
- "80"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
volumes:
postgres_data:

50
deployments/coolify.md Normal file
View File

@@ -0,0 +1,50 @@
# Coolify deployment
## Resource
Create a project and add this repository as a Docker Compose resource. Use the root `compose.yaml`. Assign the public domain to the `web` service on port 80.
Do not add custom Compose networks or Traefik labels. Coolify creates the deployment network and connects its proxy. The `web` service proxies `/api` and the SSE stream to `api`; `api` and `db` remain internal.
## Required variables
Set these as production variables in Coolify:
- `PUBLIC_URL=https://mix.example.com`
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD` — generate a unique value
- `DATABASE_URL=postgres://<user>:<password>@db:5432/<database>?sslmode=disable`
- `DISCORD_CLIENT_ID`
- `DISCORD_CLIENT_SECRET`
- `DISCORD_REDIRECT_URL=https://mix.example.com/api/auth/discord/callback`
- `ADMIN_DISCORD_IDS` — comma-separated Discord user IDs
Do not copy production values into `.env` in the repository.
## Discord
In the Discord Developer Portal:
1. Create an application.
2. Add the exact production callback URL.
3. Use only the `identify` OAuth scope.
4. Store the client secret only in Coolify.
## Storage and backups
PostgreSQL uses the `postgres_data` named volume. Confirm that Coolify recognizes it as persistent storage before accepting users.
Persistent storage is not a backup. Schedule an encrypted `pg_dump` to storage outside the VPS, define retention, and test restoration before launch. Back up before schema migrations.
## Deployment verification
1. Ensure `migrations` exits successfully.
2. Ensure `db`, `api`, and `web` become healthy.
3. Open `/health` through the public domain.
4. Complete Discord login and verify the admin role.
5. Create a test event and RSVP.
6. Restart/redeploy and confirm the event remains.
7. Verify SSE remains connected through the proxy.
Rollback application images only when the database migration is backward-compatible. Otherwise restore the matching database backup.

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log

7
frontend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Leave empty in production to use same-origin /api through nginx.
VITE_API_BASE_URL=
# Local development defaults to demo fixtures. Production builds always use the API.
# Set false to develop against the backend locally.
VITE_DEMO_MODE=true
VITE_DEV_API_TARGET=http://localhost:8080

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/health || exit 1
CMD ["nginx", "-g", "daemon off;"]

32
frontend/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d10" />
<meta name="description" content="Mixmaker community scrim control room" />
<title>Mixmaker · Scrim Control Room</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# SSE must stream each event immediately rather than buffering it.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
chunked_transfer_encoding off;
add_header X-Accel-Buffering no always;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|svg|png|jpg|jpeg|webp|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

5984
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false",
"lint": "oxlint src --deny-warnings",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run typecheck && npm run lint && npm run test && npm run build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.18",
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint-plugin-jsx-a11y": "^6.10.2",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

23
frontend/src/App.test.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import App from './App'
describe('Mixmaker frontend', () => {
afterEach(() => {
cleanup()
window.history.replaceState({}, '', '/')
})
it('renders the event dashboard', async () => {
window.history.replaceState({}, '', '/events')
render(<App />)
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
})
it('renders the Discord login screen', async () => {
window.history.replaceState({}, '', '/login')
render(<App />)
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
})
})

345
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,345 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import {
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
UsersRound, X, Zap,
} from 'lucide-react'
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Link, Outlet, RouterProvider, createRootRouteWithContext, createRoute,
createRouter, redirect,
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
type PlayerRole, type RsvpStatus, type Series, type Session, type Team,
} from './api/client'
import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
demoMode, demoRegistrations, demoSeries, demoSession,
} from './api/demo'
import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
type RouterContext = { session: Session | null }
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, mutations: { retry: 0 } } })
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: Outlet })
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: '/login', component: LoginPage })
const protectedRoute = createRoute({
getParentRoute: () => rootRoute, id: '_protected', component: AppShell,
beforeLoad: ({ context }) => { if (!context.session) throw redirect({ to: '/login' }) },
})
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
const liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage })
const spectatorRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/watch/$seriesId', component: SpectatorPage })
const bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage })
const adminRoute = createRoute({
getParentRoute: () => protectedRoute, path: '/admin', component: AdminPage,
beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
indexRoute, profileRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
])])
const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
const navItems = [
{ to: '/events', label: 'Events', icon: CalendarDays },
...(demoMode ? [
{ to: '/series/$seriesId' as const, params: { seriesId: 'series-1' }, label: 'Live', icon: Radio },
{ to: '/bracket/$eventId' as const, params: { eventId: 'bracket-1' }, label: 'Bracket', icon: Trophy },
] : []),
{ to: '/admin', label: 'Admin', icon: Shield },
]
function Brand() {
return <Link to="/events" className="brand" aria-label="Mixmaker home"><span className="brand-mark"><Swords aria-hidden="true" /></span><span><strong>MIX</strong>MAKER</span></Link>
}
function AppShell() {
const [open, setOpen] = useState(false)
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => {
queryClient.clear()
void router.navigate({ to: '/login' })
},
})
const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
return <div className="app-shell">
<header className="topbar">
<Brand />
<nav className="desktop-nav" aria-label="Primary navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon />{item.label}</Link>)}</nav>
<div className="account"><LanguageSelector /><span className="live-dot" title="Realtime connected" /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
</header>
{open && <nav className="mobile-nav" aria-label="Mobile navigation">{[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} onClick={() => setOpen(false)}><Icon />{item.label}</Link>)}</nav>}
<main><Outlet /></main>
<nav className="bottom-nav" aria-label="Quick navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon /><span>{item.label}</span></Link>)}</nav>
</div>
}
function LoginPage() {
return <div className="login-page">
<div className="login-orb login-orb-orange" /><div className="login-orb login-orb-blue" />
<section className="login-card"><div className="login-brand-row"><Brand /><LanguageSelector /></div><span className="eyebrow">Community scrims, organized</span><h1>Ready up.<br /><span>Play together.</span></h1><p>Balance teams, run drafts, and follow every map from one esports control room.</p><a className="button discord-button" href="/api/auth/discord"><Gamepad2 />Continue with Discord</a><small>Signing in creates a secure session. We never receive your Discord password.</small></section>
<div className="login-stats"><span><strong>5v5</strong> role-locked teams</span><span><strong>LIVE</strong> synchronized drafts</span><span><strong>BO3</strong> tournament series</span></div>
</div>
}
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return <header className="page-header"><div><span className="eyebrow">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>{actions && <div className="page-actions">{actions}</div>}</header>
}
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry }: { retry?: () => void }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>Your data is safe. Check your connection and try again.</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
function EventsPage() {
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
if (events.isLoading) return <LoadingState label="Loading events…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
if (!events.data) return <LoadingState label="Loading events…" />
const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data
return <div className="page">
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
{demoMode && <section className="featured-event"><div><Badge tone="live"><Radio />Live now</Badge><h2>{demoEvents[2].title}</h2><p>Semifinal 1 · Map 2 draft in progress</p></div><div className="featured-score"><span>EMBER</span><strong>1<i>:</i>0</strong><span>AZURE</span></div><Link className="button primary" to="/watch/$seriesId" params={{ seriesId: 'series-1' }}>Watch live <ChevronRight /></Link></section>}
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.filter((e) => e.status !== 'live').map((event) => <EventCard key={event.id} event={event} />)}</div>}
</div>
}
function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt)
return <article className="card event-card"><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta"><Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge><span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><Link to="/events/$eventId" params={{ eventId: event.id }}>View event <ChevronRight /></Link></div></div></article>
}
function EventPage() {
const { eventId } = eventRoute.useParams()
const client = useQueryClient()
const demoEvent = demoEvents.find((event) => event.id === eventId)
const eventQuery = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvent : undefined })
const registrations = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const rsvpMutation = useMutation({
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
},
})
useEffect(() => subscribeToEvents(`event:${eventId}`, () => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
}), [client, eventId])
const labels: Record<RsvpStatus, string> = { going: 'Going', maybe: 'Maybe', not_going: 'Cant go' }
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return <LoadingState label="Loading event…" />
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return <ErrorState retry={() => { void eventQuery.refetch(); void registrations.refetch(); void session.refetch() }} />
const event = eventQuery.data
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
return <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</Badge>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>Your latest response is sent to the organizer.</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div></div>
}
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
const options = useMemo(() => rankOptions(language), [language])
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined })
const players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoRegistrations.map((registration) => registration.player) : undefined })
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
useEffect(() => {
if (profile.data) {
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds)
}
}, [profile.data])
const update = useMutation({
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds),
onSuccess: (player) => {
client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] })
},
})
if (profile.isLoading || players.isLoading) return <LoadingState label="Loading profile…" />
if (profile.isError || players.isError) return <ErrorState retry={() => { void profile.refetch(); void players.refetch() }} />
if (!profile.data || !players.data) return <LoadingState label="Loading profile…" />
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{players.data.filter((player) => player.id !== profile.data.id).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
}
type AdminTab = 'events' | 'participants' | 'balance'
function AdminPage() {
const [tab, setTab] = useState<AdminTab>('events')
const [createMode, setCreateMode] = useState(false)
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
const [selectedId, setSelectedId] = useState<string>()
const selected = events.data?.find((event) => event.id === selectedId) ?? events.data?.[0]
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading) return <LoadingState label="Loading admin control room…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<><select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data?.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select><button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
}
function toLocalInput(value: string) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)
}
function eventInput(event?: MixEvent): EventInput {
const start = new Date(Date.now() + 86_400_000)
const end = new Date(start.getTime() + 4 * 3_600_000)
const deadline = new Date(start.getTime() - 3_600_000)
return {
name: event?.title ?? '',
description: event?.description ?? '',
startsAt: toLocalInput(event?.startsAt ?? start.toISOString()),
endsAt: toLocalInput(event?.endsAt ?? end.toISOString()),
registrationDeadline: toLocalInput(event?.registrationDeadline ?? deadline.toISOString()),
}
}
function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event: MixEvent) => void }) {
const client = useQueryClient()
const initialForm = useMemo(() => eventInput(event), [event])
const [form, setForm] = useState<EventInput>(initialForm)
useEffect(() => setForm(initialForm), [initialForm])
const save = useMutation({
mutationFn: () => {
const payload = { ...form, startsAt: new Date(form.startsAt).toISOString(), endsAt: new Date(form.endsAt).toISOString(), registrationDeadline: new Date(form.registrationDeadline).toISOString() }
return event ? api.updateEvent(event.id, payload) : api.createEvent(payload)
},
onSuccess: (saved) => {
void client.invalidateQueries({ queryKey: ['events'] })
onCreated(saved)
},
})
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
return <div className="admin-grid"><form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}><div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <Badge tone="success">Published</Badge>}</div><div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div><label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label><div className="form-footer"><p>{save.isError ? save.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div></form><aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside></div>
}
function Participants({ eventId }: { eventId: string }) {
const client = useQueryClient()
const language = useLanguage()
const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const override = useMutation({
mutationFn: ({ playerId, status }: { playerId: string; status: RsvpStatus }) => api.setRsvp(eventId, playerId, status),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
},
})
if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
return <section className="card table-card"><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
}
function BalanceView({ eventId }: { eventId: string }) {
const client = useQueryClient()
const existingTeams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
const generate = useMutation({ mutationFn: () => api.generateBalance(eventId) })
const candidates = useMemo(() => generate.data ?? (demoMode ? demoBalanceCandidates : []), [generate.data])
const [selected, setSelected] = useState<string>()
useEffect(() => { if (!selected && candidates[0]) setSelected(candidates[0].id) }, [candidates, selected])
const candidate = candidates.find((item) => item.id === selected) ?? candidates[0]
const select = useMutation({
mutationFn: () => api.selectBalance(eventId, candidate),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
if (existingTeams.isLoading) return <LoadingState label="Loading teams…" />
if (!candidate) return <div className="state-card"><Sparkles /><h2>Generate balance candidates</h2><p>The backend uses confirmed Going players and returns three ranked alternatives.</p><button className="button primary" disabled={generate.isPending} onClick={() => generate.mutate()}>{generate.isPending ? 'Balancing…' : 'Generate on server'}</button>{generate.isError && <p className="error-note">{generate.error.message}</p>}{existingTeams.data && existingTeams.data.length > 0 && <div className="teams-grid">{existingTeams.data.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div>}</div>
return <div className="balance-layout"><aside className="candidate-list"><div className="section-title"><div><h2>Server candidates</h2><p>Lower imbalance score is better</p></div><button className="icon-button" aria-label="Generate again" disabled={generate.isPending} onClick={() => generate.mutate()}><RefreshCw /></button></div>{candidates.map((item, i) => <button key={item.id} className={`candidate-card ${selected === item.id ? 'active' : ''}`} onClick={() => setSelected(item.id)}><span className="candidate-rank">0{i + 1}</span><span><strong>Score {item.score}</strong><small>Computed by backend balancer</small></span><ChevronRight /></button>)}</aside><section className="balance-main"><div className="balance-score card"><div><span className="eyebrow">Imbalance score</span><strong>{candidate.score}</strong></div><div className="balance-explanations"><span>Preference fit</span>{candidate.explanations.map((explanation) => <small key={explanation}><Check />{explanation}</small>)}</div><p>Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.</p></div><div className="teams-grid">{candidate.teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="reserve-bar card"><div><UsersRound /><span><strong>Reserve · {candidate.reserve.length}</strong><small>Not assigned to a complete 5v5 roster</small></span></div><div>{candidate.reserve.map((p) => <Badge key={p.id}>{p.displayName}</Badge>)}</div></div><div className="sticky-action"><p>{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}><Check />{select.isPending ? 'Saving…' : 'Use this balance'}</button></div></section></div>
}
function TeamCard({ team, eventId }: { team: Team; eventId: string }) {
const client = useQueryClient()
const language = useLanguage()
const [captain, setCaptain] = useState(team.captainId ?? '')
useEffect(() => setCaptain(team.captainId ?? ''), [team.captainId])
const assign = useMutation({
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div><label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label></article>
}
function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
const [selected, setSelected] = useState<string>()
const [mapName, setMapName] = useState('')
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
const result = useMutation({
mutationFn: () => api.recordResult(seriesId, mapName, outcome, series.data!.version),
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
setMapName('')
},
})
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => {
void client.invalidateQueries({ queryKey: ['series', seriesId] })
}), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError) return <ErrorState retry={() => void series.refetch()} />
if (!series.data) return <LoadingState />
const data = series.data
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Map name<input required value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="e.g. Lijiang Tower" /></label><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={!selected}>Confirm action <ChevronRight /></button></div></>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
}
function Scoreboard({ series }: { series: Series }) {
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player.displayName
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
return <section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>
}
function MapTimeline({ series }: { series: Series }) { return <section className="card map-timeline"><div className="section-title"><div><h2>Map timeline</h2><p>First to 2 wins</p></div></div>{series.maps.length === 0 ? <p className="empty-note">No results recorded.</p> : series.maps.map((map) => <div className={`map-row ${map.status}`} key={map.number}><span>0{map.number}</span><div><strong>{map.name}</strong><small>{map.mode} · {map.status}</small></div>{map.winner && <Badge tone={map.winner === 'alpha' ? 'warning' : 'blue'}>{map.winner === 'alpha' ? 'A' : map.winner === 'beta' ? 'B' : 'Draw'}</Badge>}</div>)}</section> }
function AuditLog({ series }: { series: Series }) { return <section className="card audit-log"><div className="section-title"><div><h2>Match history</h2><p>Auditable server actions</p></div></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : series.audit.slice(-4).reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</section> }
function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => void client.invalidateQueries({ queryKey: ['series', seriesId] })), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError || !series.data) return <ErrorState retry={() => void series.refetch()} />
const data = series.data
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{demoMode && data.currentStep.kind === 'hero_ban' && <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div>}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
}
function BracketPage() {
const { eventId: tournamentId } = bracketRoute.useParams()
const bracket = useQuery({ queryKey: ['tournament', tournamentId], queryFn: () => api.bracket(tournamentId), initialData: demoMode && tournamentId === demoBracket.id ? demoBracket : undefined })
if (bracket.isLoading) return <LoadingState label="Loading bracket…" />
if (bracket.isError || !bracket.data) return <ErrorState retry={() => void bracket.refetch()} />
const data = bracket.data
return <div className="page bracket-page"><PageHeader eyebrow="Single elimination" title={data.title} description="Winners advance automatically when the server completes a series." actions={<Badge tone="live"><Radio />{data.matches.filter((match) => match.status === 'live').length} live</Badge>} /><div className="bracket-scroll" tabIndex={0} aria-label="Tournament bracket">{data.rounds.map((round, ri) => <section className="bracket-round" key={round}><header><span>Round {ri + 1}</span><h2>{round}</h2></header><div className="bracket-matches">{data.matches.filter((m) => m.round === ri).map((m) => <article className={`bracket-match ${m.status}`} key={m.id}><div className="match-label"><span>{m.label}</span>{m.status === 'live' && <Badge tone="live">Live</Badge>}</div><div className={m.winner === 'alpha' ? 'winner' : ''}><span>{m.teamAlpha ?? 'TBD'}</span><strong>{m.scoreAlpha ?? '—'}</strong></div><div className={m.winner === 'beta' ? 'winner' : ''}><span>{m.teamBeta ?? 'TBD'}</span><strong>{m.scoreBeta ?? '—'}</strong></div>{m.seriesId && <Link to="/watch/$seriesId" params={{ seriesId: m.seriesId }}>Open series <ChevronRight /></Link>}</article>)}</div></section>)}</div><p className="bracket-help"><ChevronRight />Scroll horizontally to follow the bracket on smaller screens.</p></div>
}
function App() {
return <QueryClientProvider client={queryClient}><AppRouter /></QueryClientProvider>
}
function AppRouter() {
const session = useQuery({
queryKey: ['session'],
queryFn: api.session,
initialData: demoMode ? demoSession : undefined,
retry: false,
})
const context = useMemo(() => ({ session: session.data ?? null }), [session.data])
if (session.isLoading) return <LoadingState label="Restoring your session…" />
return <RouterProvider router={router} context={context} />
}
export default App

View File

@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adaptEvent, adaptPlayer, adaptSeries, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
const player: RawPlayer = {
id: 'player-1',
accountId: 'account-1',
displayName: 'Nova',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-18T00:00:00Z',
}
afterEach(() => vi.restoreAllMocks())
describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
})
})
it('adapts backend RSVP values and counts', () => {
const event: RawEvent = {
id: 'event-1',
name: 'Scrim',
description: '',
startsAt: '2099-07-20T19:00:00Z',
endsAt: '2099-07-20T23:00:00Z',
registrationDeadline: '2099-07-20T18:00:00Z',
createdBy: 'account-1',
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
}
const adapted = adaptEvent(event, [
{ eventId: event.id, playerId: player.id, actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: event.updatedAt },
], player.id)
expect(adapted.myRsvp).toBe('going')
expect(adapted.counts.going).toBe(1)
})
it('keeps corrected results out of the authoritative score', () => {
const series: RawSeries = {
id: 'series-1',
tournamentId: 't-1',
teamAId: 'a',
teamBId: 'b',
winnerTeamId: '',
bestOf: 3,
version: 2,
results: [
{ mapName: 'Lijiang', outcome: 'TeamAWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:00:00Z' },
{ mapName: 'Lijiang', outcome: 'TeamBWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:01:00Z', correctionOf: 1 },
],
}
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
})
})
describe('actual backend routes', () => {
it('updates the current player through PATCH /api/me/player', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(player), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
})
it('sends backend RSVP enum values to the player-specific route', async () => {
const responses = [
{ eventId: 'event-1', playerId: 'player-1', actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: '2026-07-18T00:00:00Z' },
[player],
{ account: { id: 'account-1', discordId: 'discord', username: 'Nova', avatarUrl: '', role: 'player', createdAt: '2026-07-01T00:00:00Z' }, player },
]
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify(responses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.setRsvp('event-1', 'player-1', 'going')
const rsvpCall = fetchMock.mock.calls.find(([url]) => url === '/api/events/event-1/rsvps/player-1')
expect(rsvpCall?.[1]?.body).toBe(JSON.stringify({ status: 'Going' }))
})
})

529
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,529 @@
import { toRankOrdinal, type RankOrdinal } from '../ranks'
export type AccountRole = 'admin' | 'player'
export type RsvpStatus = 'going' | 'maybe' | 'not_going'
export type PlayerRole = 'tank' | 'damage' | 'support'
export type TeamSide = 'alpha' | 'beta'
export type MapOutcome = 'TeamAWin' | 'TeamBWin' | 'Draw'
export interface Ratings { tank: RankOrdinal; damage: RankOrdinal; support: RankOrdinal; updatedAt: string }
export interface Player {
id: string
accountId?: string
displayName: string
avatarUrl?: string
battleTag?: string
ratings: Ratings
preferredRoles: PlayerRole[]
preferredPlayerIds: string[]
}
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
player: Player
}
export interface Registration {
id: string
player: Player
status: RsvpStatus
updatedAt: string
changedBy?: { id: string; displayName: string; source: 'self' | 'admin' }
}
export interface MixEvent {
id: string
title: string
description: string
startsAt: string
endsAt: string
registrationDeadline: string
status: 'registration' | 'balancing' | 'live' | 'completed'
rulesetName: string
counts: Record<RsvpStatus, number>
myRsvp?: RsvpStatus
}
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
eventId?: string
name: string
side: TeamSide
captainId?: string
averageRating: number
members: TeamPlayer[]
}
export interface BalanceCandidate {
id: string
score: number
teams: Team[]
reserve: Player[]
explanations: string[]
payload?: RawBalanceCandidate
}
export interface DraftOption {
id: string
name: string
role?: PlayerRole
mode?: string
disabled?: boolean
disabledReason?: string
}
export interface DraftAction {
id: string
kind: 'coin_toss' | 'map_ban' | 'map_selected' | 'hero_ban' | 'result'
summary: string
actorName: string
createdAt: string
}
export interface Series {
id: string
tournamentId?: string
status: 'scheduled' | 'coin_toss' | 'map_draft' | 'hero_draft' | 'playing' | 'completed'
roundLabel: string
teamAlpha: Team
teamBeta: Team
score: { alpha: number; beta: number }
version: number
currentStep: {
title: string
instruction: string
activeTeam?: TeamSide
kind: 'coin_toss' | 'map_ban' | 'hero_ban' | 'result' | 'complete'
version: number
}
options: DraftOption[]
maps: Array<{
number: number
name: string
mode: string
status: 'drafting' | 'ready' | 'playing' | 'completed'
winner?: TeamSide | 'draw'
heroBans: Array<{ hero: string; team: TeamSide; role: PlayerRole }>
}>
audit: DraftAction[]
}
export interface BracketMatch {
id: string; round: number; slot: number; label: string
teamAlpha?: string; teamBeta?: string; scoreAlpha?: number; scoreBeta?: number
winner?: TeamSide; seriesId?: string; status: 'pending' | 'ready' | 'live' | 'completed'
}
export interface Bracket { id: string; title: string; rounds: string[]; matches: BracketMatch[] }
export interface EventInput {
name: string
description: string
startsAt: string
endsAt: string
registrationDeadline: string
}
// Raw backend DTOs mirror Go's current camelCase JSON tags exactly.
export interface RawAccount {
id: string; discordId: string; username: string; avatarUrl: string
role: AccountRole; createdAt: string
}
export interface RawRatings { tank: number; damage: number; support: number }
export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
}
export interface RawRSVP {
eventId: string; playerId: string; actorAccountId: string
status: 'Going' | 'Maybe' | 'NotGoing'; source: 'player' | 'admin'; updatedAt: string
}
export interface RawSlot { playerId: string; role: 'Tank' | 'Damage' | 'Support'; rating: number }
export interface RawTeam {
id: string; eventId: string; name: string; captainPlayerId: string; slots: RawSlot[]
}
export interface RawBalanceCandidate { teams: RawTeam[]; reserve: string[]; score: number; explanation?: string[] }
export interface RawMapResult {
mapName: string; actorAccountId: string; outcome: MapOutcome
recordedAt: string; correctionOf?: number
}
export interface RawSeries {
id: string; tournamentId: string; teamAId: string; teamBId: string
winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number
}
export interface RawTournament {
id: string; eventId: string; name: string; winnerTeamId: string
teamIds: string[]; rounds: RawSeries[][]
}
export interface RawRealtimeEvent { topic: string; data: unknown }
const roleFromRaw = (role: RawSlot['role']): PlayerRole =>
role === 'Tank' ? 'tank' : role === 'Damage' ? 'damage' : 'support'
const statusFromRaw = (status: RawRSVP['status']): RsvpStatus =>
status === 'Going' ? 'going' : status === 'Maybe' ? 'maybe' : 'not_going'
const statusToRaw = (status: RsvpStatus): RawRSVP['status'] =>
status === 'going' ? 'Going' : status === 'maybe' ? 'Maybe' : 'NotGoing'
export function adaptPlayer(raw: RawPlayer): Player {
const ratings = {
tank: toRankOrdinal(raw.ratings.tank),
damage: toRankOrdinal(raw.ratings.damage),
support: toRankOrdinal(raw.ratings.support),
updatedAt: raw.updatedAt,
}
const preferredRoles = (raw.preferredRoles ?? []).map((role) =>
role === 'Tank' ? 'tank' : role === 'Damage' ? 'damage' : 'support',
)
return {
id: raw.id,
accountId: raw.accountId,
displayName: raw.displayName,
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
}
}
export function adaptSession(raw: RawSession): Session {
return {
account: {
id: raw.account.id,
discordId: raw.account.discordId,
displayName: raw.account.username,
avatarUrl: raw.account.avatarUrl || undefined,
role: raw.account.role,
},
player: adaptPlayer(raw.player),
}
}
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now()
const starts = new Date(raw.startsAt).getTime()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 }
rsvps.forEach((rsvp) => { counts[statusFromRaw(rsvp.status)]++ })
const own = rsvps.find((rsvp) => rsvp.playerId === currentPlayerId)
return {
id: raw.id,
title: raw.name,
description: raw.description,
startsAt: raw.startsAt,
endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline,
status: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration',
rulesetName: 'Server ruleset',
counts,
myRsvp: own ? statusFromRaw(own.status) : undefined,
}
}
export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, session?: Session): Registration {
const player = players.get(raw.playerId) ?? {
id: raw.playerId,
displayName: `Player ${raw.playerId.slice(0, 6)}`,
ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt },
preferredRoles: [],
preferredPlayerIds: [],
}
return {
id: `${raw.eventId}:${raw.playerId}`,
player,
status: statusFromRaw(raw.status),
updatedAt: raw.updatedAt,
changedBy: {
id: raw.actorAccountId,
displayName: raw.actorAccountId === session?.account.id ? session.account.displayName : raw.source === 'admin' ? 'Administrator' : player.displayName,
source: raw.source === 'admin' ? 'admin' : 'self',
},
}
}
export function adaptTeam(raw: RawTeam, players: Map<string, Player>, index = 0): Team {
const members = raw.slots.map((slot) => ({
player: players.get(slot.playerId) ?? {
id: slot.playerId,
displayName: `Player ${slot.playerId.slice(0, 6)}`,
ratings: {
tank: toRankOrdinal(slot.rating),
damage: toRankOrdinal(slot.rating),
support: toRankOrdinal(slot.rating),
updatedAt: '',
},
preferredRoles: [roleFromRaw(slot.role)],
preferredPlayerIds: [],
},
assignedRole: roleFromRaw(slot.role),
}))
const averageRating = raw.slots.length
? raw.slots.reduce((sum, slot) => sum + slot.rating, 0) / raw.slots.length
: 0
return {
id: raw.id,
eventId: raw.eventId,
name: raw.name,
side: index % 2 === 0 ? 'alpha' : 'beta',
captainId: raw.captainPlayerId || undefined,
averageRating: Number(averageRating.toFixed(1)),
members,
}
}
export function adaptBalance(raw: RawBalanceCandidate, players: Player[], index: number): BalanceCandidate {
const byId = new Map(players.map((player) => [player.id, player]))
const teamForPlayer = new Map(raw.teams.flatMap((team) => team.slots.map((slot) => [slot.playerId, team.id] as const)))
const preferredRoleAssignments = raw.teams.flatMap((team) => team.slots).filter((slot) =>
byId.get(slot.playerId)?.preferredRoles.includes(roleFromRaw(slot.role)),
).length
const preferredPairs = players.reduce((count, player) =>
count + player.preferredPlayerIds.filter((preferredId) =>
teamForPlayer.get(player.id) && teamForPlayer.get(player.id) === teamForPlayer.get(preferredId),
).length, 0) / 2
return {
id: `candidate-${index + 1}`,
score: raw.score,
teams: raw.teams.map((team, teamIndex) => adaptTeam(team, byId, teamIndex)),
reserve: raw.reserve.map((id) => byId.get(id)).filter((player): player is Player => Boolean(player)),
explanations: raw.explanation ?? [
`${preferredRoleAssignments} preferred role assignments`,
`${Math.floor(preferredPairs)} preferred teammate pairs kept together`,
],
payload: raw,
}
}
export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
const alpha = teams.find((team) => team.id === raw.teamAId) ?? emptyTeam(raw.teamAId, 'alpha')
const beta = teams.find((team) => team.id === raw.teamBId) ?? emptyTeam(raw.teamBId, 'beta')
const activeResults = raw.results.filter((_, index) =>
!raw.results.some((correction) => correction.correctionOf === index + 1),
)
const score = activeResults.reduce((total, result) => ({
alpha: total.alpha + (result.outcome === 'TeamAWin' ? 1 : 0),
beta: total.beta + (result.outcome === 'TeamBWin' ? 1 : 0),
}), { alpha: 0, beta: 0 })
const complete = Boolean(raw.winnerTeamId)
return {
id: raw.id,
tournamentId: raw.tournamentId,
status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled',
roundLabel: 'Tournament series',
teamAlpha: alpha,
teamBeta: beta,
score,
version: raw.version,
currentStep: {
title: complete ? 'Series complete' : `Record map ${activeResults.length + 1}`,
instruction: complete ? 'The tournament bracket has advanced.' : 'An admin can record the authoritative map result.',
kind: complete ? 'complete' : 'result',
version: raw.version,
},
options: [],
maps: activeResults.map((result, index) => ({
number: index + 1,
name: result.mapName,
mode: 'Map',
status: 'completed',
winner: result.outcome === 'TeamAWin' ? 'alpha' : result.outcome === 'TeamBWin' ? 'beta' : 'draw',
heroBans: [],
})),
audit: raw.results.map((result, index) => ({
id: `result-${index}`,
kind: 'result',
summary: `${result.mapName}: ${result.outcome === 'TeamAWin' ? alpha.name : result.outcome === 'TeamBWin' ? beta.name : 'Draw'}`,
actorName: result.actorAccountId,
createdAt: result.recordedAt,
})),
}
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
const names = new Map(teams.map((team) => [team.id, team.name]))
return {
id: raw.id,
title: raw.name,
rounds: raw.rounds.map((_, index) => index === raw.rounds.length - 1 ? 'Grand Final' : index === raw.rounds.length - 2 ? 'Semifinals' : `Round ${index + 1}`),
matches: raw.rounds.flatMap((round, roundIndex) => round.map((series, slot) => {
const adapted = adaptSeries(series, teams)
return {
id: series.id,
round: roundIndex,
slot,
label: `Match ${slot + 1}`,
teamAlpha: names.get(series.teamAId) ?? (series.teamAId || undefined),
teamBeta: names.get(series.teamBId) ?? (series.teamBId || undefined),
scoreAlpha: adapted.score.alpha,
scoreBeta: adapted.score.beta,
winner: series.winnerTeamId ? (series.winnerTeamId === series.teamAId ? 'alpha' : 'beta') : undefined,
seriesId: series.id,
status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId ? 'ready' : 'pending',
} satisfies BracketMatch
})),
}
}
function emptyTeam(id: string, side: TeamSide): Team {
return { id, name: id ? `Team ${id.slice(0, 8)}` : 'TBD', side, averageRating: 0, members: [] }
}
export class ApiError extends Error {
readonly status: number
readonly details?: unknown
constructor(message: string, status: number, details?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.details = details
}
}
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (!response.ok) {
const details = await response.json().catch(() => undefined)
throw new ApiError(
typeof details === 'object' && details && 'error' in details ? String(details.error) : `Request failed (${response.status})`,
response.status,
details,
)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
const raw = {
session: () => request<RawSession>('/auth/me'),
players: () => request<RawPlayer[]>('/players'),
events: () => request<RawEvent[]>('/events'),
event: (id: string) => request<RawEvent>(`/events/${id}`),
rsvps: (eventId: string) => request<RawRSVP[]>(`/events/${eventId}/rsvps`),
teams: (eventId: string) => request<RawTeam[]>(`/events/${eventId}/teams`),
}
async function adaptSeriesWithTeams(series: RawSeries) {
if (!series.tournamentId) return adaptSeries(series)
const tournament = await request<RawTournament>(`/tournaments/${series.tournamentId}`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptSeries(series, teams.map((team, index) => adaptTeam(team, byId, index)))
}
export const api = {
session: async () => adaptSession(await raw.session()),
logout: () => request<void>('/auth/logout', { method: 'POST' }),
profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async (
displayName: string,
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
) =>
adaptPlayer(await request<RawPlayer>('/me/player', {
method: 'PATCH',
body: JSON.stringify({
displayName,
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
events: async () => {
const [events, session] = await Promise.all([raw.events(), raw.session()])
return Promise.all(events.map(async (event) => {
const rsvps = await raw.rsvps(event.id)
return adaptEvent(event, rsvps, session.player.id)
}))
},
event: async (eventId: string) => {
const [event, rsvps, session] = await Promise.all([raw.event(eventId), raw.rsvps(eventId), raw.session()])
return adaptEvent(event, rsvps, session.player.id)
},
registrations: async (eventId: string) => {
const [rsvps, players, session] = await Promise.all([raw.rsvps(eventId), raw.players(), raw.session()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return rsvps.map((rsvp) => adaptRegistration(rsvp, byId, adaptSession(session)))
},
setRsvp: async (eventId: string, playerId: string, status: RsvpStatus) => {
const [result, players, session] = await Promise.all([
request<RawRSVP>(`/events/${eventId}/rsvps/${playerId}`, { method: 'PUT', body: JSON.stringify({ status: statusToRaw(status) }) }),
raw.players(),
raw.session(),
])
return adaptRegistration(result, new Map(players.map(adaptPlayer).map((player) => [player.id, player])), adaptSession(session))
},
createEvent: async (event: EventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
generateBalance: async (eventId: string) => {
const [candidates, players] = await Promise.all([
request<RawBalanceCandidate[]>(`/events/${eventId}/balance`, { method: 'POST' }),
raw.players(),
])
const adaptedPlayers = players.map(adaptPlayer)
return candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index))
},
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT',
body: JSON.stringify(candidate.payload ?? {
teams: candidate.teams.map((team) => ({
id: team.id,
eventId,
name: team.name,
captainPlayerId: team.captainId ?? '',
slots: team.members.map(({ player, assignedRole }) => ({
playerId: player.id,
role: assignedRole === 'tank' ? 'Tank' : assignedRole === 'damage' ? 'Damage' : 'Support',
rating: player.ratings[assignedRole],
})),
})),
reserve: candidate.reserve.map((player) => player.id),
score: candidate.score,
} satisfies RawBalanceCandidate),
}),
teams: async (eventId: string) => {
const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return teams.map((team, index) => adaptTeam(team, byId, index))
},
assignCaptain: async (teamId: string, playerId: string) =>
adaptTeam(await request<RawTeam>(`/teams/${teamId}/captain`, { method: 'PUT', body: JSON.stringify({ playerId }) }), new Map()),
series: async (seriesId: string) => {
const series = await request<RawSeries>(`/series/${seriesId}`)
return adaptSeriesWithTeams(series)
},
recordResult: async (seriesId: string, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/results`, {
method: 'POST',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
correctResult: async (seriesId: string, index: number, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/results/${index}`, {
method: 'PATCH',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
coinToss: (input: { teamAId: string; teamBId: string; seed: string; eventId: string; actingTeamId: string }) =>
request<{ seed: string; winnerTeamId: string; performedAt: string }>('/coin-toss', { method: 'POST', body: JSON.stringify(input) }),
mapBan: (draftId: string, input: { teamId: string; map: string; eventId: string; version: number }) =>
request<{ draft: unknown; version: number }>(`/drafts/${draftId}/map-bans`, { method: 'POST', body: JSON.stringify(input) }),
heroBan: (draftId: string, input: { teamId: string; hero: string; eventId: string; version: number }) =>
request<{ draft: unknown; version: number }>(`/drafts/${draftId}/hero-bans`, { method: 'POST', body: JSON.stringify(input) }),
bracket: async (eventId: string) => {
const tournament = await request<RawTournament>(`/events/${eventId}/tournament`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptTournament(tournament, teams.map((team, index) => adaptTeam(team, byId, index)))
},
}
export function subscribeToEvents(
topic: string,
onMessage: (event: RawRealtimeEvent) => void,
onStatus?: (status: 'connected' | 'reconnecting') => void,
) {
const source = new EventSource(`${API_BASE}/events/stream?topic=${encodeURIComponent(topic)}`, { withCredentials: true })
source.onopen = () => onStatus?.('connected')
source.onerror = () => onStatus?.('reconnecting')
source.addEventListener('update', (event) => {
try { onMessage(JSON.parse(event.data) as RawRealtimeEvent) } catch { /* next query refresh reconciles state */ }
})
return () => source.close()
}

248
frontend/src/api/demo.ts Normal file
View File

@@ -0,0 +1,248 @@
import type {
BalanceCandidate,
Bracket,
MixEvent,
Player,
Registration,
Series,
Session,
Team,
} from './client'
import type { RankOrdinal } from '../ranks'
// Fixtures keep local development and visual review useful without a backend.
// Production builds always use the real API.
export const demoMode =
import.meta.env.DEV && import.meta.env.VITE_DEMO_MODE !== 'false'
const now = new Date()
const daysFromNow = (days: number, hour = 19) => {
const date = new Date(now)
date.setDate(date.getDate() + days)
date.setHours(hour, 0, 0, 0)
return date.toISOString()
}
const makePlayer = (
id: string,
displayName: string,
tank: RankOrdinal,
damage: RankOrdinal,
support: RankOrdinal,
): Player => ({
id,
displayName,
battleTag: `${displayName.replace(/\s/g, '')}#${1000 + Number(id.replace(/\D/g, '') || 0)}`,
ratings: { tank, damage, support, updatedAt: daysFromNow(-4) },
preferredRoles: tank >= damage && tank >= support ? ['tank'] : damage >= support ? ['damage'] : ['support'],
preferredPlayerIds: [],
})
export const demoProfile: Player = {
...makePlayer('p1', 'Lemintare', 30, 33, 27),
preferredRoles: ['damage', 'tank'],
preferredPlayerIds: ['p2', 'p5'],
}
export const demoSession: Session = {
account: {
id: 'account-1',
discordId: '125661705720381444',
displayName: 'Lemintare',
role: 'admin',
},
player: demoProfile,
}
export const demoEvents: MixEvent[] = [
{
id: 'event-1',
title: 'Saturday Night Scrim',
description: 'Community draft night · 4 teams · Bo3 single elimination',
startsAt: daysFromNow(2),
endsAt: daysFromNow(2, 23),
registrationDeadline: daysFromNow(1, 21),
status: 'registration',
rulesetName: 'Community Bo3 v2',
counts: { going: 18, maybe: 4, not_going: 3 },
myRsvp: 'going',
},
{
id: 'event-2',
title: 'Midweek Mix',
description: 'Fast queue with two balanced teams',
startsAt: daysFromNow(5),
endsAt: daysFromNow(5, 23),
registrationDeadline: daysFromNow(4),
status: 'registration',
rulesetName: 'Quick Mix v1',
counts: { going: 9, maybe: 6, not_going: 2 },
myRsvp: 'maybe',
},
{
id: 'event-3',
title: 'Summer Clash #4',
description: 'Live playoff bracket',
startsAt: daysFromNow(0, 20),
endsAt: daysFromNow(1, 1),
registrationDeadline: daysFromNow(-1),
status: 'live',
rulesetName: 'Tournament Bo3 v2',
counts: { going: 22, maybe: 0, not_going: 4 },
myRsvp: 'going',
},
]
const players = [
demoProfile,
makePlayer('p2', 'Nova', 36, 28, 29),
makePlayer('p3', 'Kairo', 24, 35, 26),
makePlayer('p4', 'MercyMain', 18, 27, 37),
makePlayer('p5', 'Pulse', 25, 34, 29),
makePlayer('p6', 'Astra', 31, 25, 36),
makePlayer('p7', 'Hex', 35, 29, 23),
makePlayer('p8', 'Orbit', 22, 34, 30),
makePlayer('p9', 'Miko', 27, 30, 36),
makePlayer('p10', 'Rook', 37, 26, 24),
makePlayer('p11', 'Echo', 26, 32, 31),
makePlayer('p12', 'Zenith', 28, 30, 35),
]
players[1].preferredPlayerIds = ['p1']
players[4].preferredPlayerIds = ['p1']
export const demoRegistrations: Registration[] = players.map((player, index) => ({
id: `reg-${index + 1}`,
player,
status: index < 9 ? 'going' : index < 11 ? 'maybe' : 'not_going',
updatedAt: daysFromNow(-(index % 4)),
changedBy:
index === 4
? { id: 'account-1', displayName: 'Lemintare', source: 'admin' }
: { id: player.id, displayName: player.displayName, source: 'self' },
}))
const alpha: Team = {
id: 'team-alpha',
name: 'Ember Wolves',
side: 'alpha',
captainId: 'p2',
averageRating: 35.6,
members: [
{ player: players[1], assignedRole: 'tank' },
{ player: players[2], assignedRole: 'damage' },
{ player: players[4], assignedRole: 'damage' },
{ player: players[3], assignedRole: 'support' },
{ player: players[5], assignedRole: 'support' },
],
}
const beta: Team = {
id: 'team-beta',
name: 'Azure Phantoms',
side: 'beta',
captainId: 'p7',
averageRating: 34.4,
members: [
{ player: players[6], assignedRole: 'tank' },
{ player: players[7], assignedRole: 'damage' },
{ player: players[10], assignedRole: 'damage' },
{ player: players[8], assignedRole: 'support' },
{ player: players[11], assignedRole: 'support' },
],
}
export const demoBalanceCandidates: BalanceCandidate[] = [
{
id: 'candidate-1',
score: 4,
teams: [alpha, beta],
reserve: [players[0], players[9]],
explanations: ['9 preferred role assignments', '2 preferred teammate pairs kept together'],
},
{
id: 'candidate-2',
score: 9,
teams: [
{ ...alpha, averageRating: 35.2 },
{ ...beta, averageRating: 34.6 },
],
reserve: [players[0], players[9]],
explanations: ['8 preferred role assignments', '2 preferred teammate pairs kept together'],
},
{
id: 'candidate-3',
score: 13,
teams: [
{ ...alpha, averageRating: 35.4 },
{ ...beta, averageRating: 34.5 },
],
reserve: [players[0], players[9]],
explanations: ['10 preferred role assignments', '1 preferred teammate pair kept together'],
},
]
export const demoSeries: Series = {
id: 'series-1',
status: 'hero_draft',
roundLabel: 'Semifinal 1',
teamAlpha: alpha,
teamBeta: beta,
score: { alpha: 1, beta: 0 },
version: 12,
currentStep: {
title: 'Hero ban 2 of 4',
instruction: 'Azure Phantoms choose a hero to remove for Map 2.',
activeTeam: 'beta',
kind: 'hero_ban',
version: 12,
},
options: [
{ id: 'hero-1', name: 'Ana', role: 'support' },
{ id: 'hero-2', name: 'Kiriko', role: 'support' },
{ id: 'hero-3', name: 'Tracer', role: 'damage' },
{ id: 'hero-4', name: 'Sojourn', role: 'damage' },
{ id: 'hero-5', name: 'Winston', role: 'tank', disabled: true, disabledReason: 'Already banned by this team' },
{ id: 'hero-6', name: 'Sigma', role: 'tank' },
],
maps: [
{
number: 1,
name: 'Lijiang Tower',
mode: 'Control',
status: 'completed',
winner: 'alpha',
heroBans: [
{ hero: 'Sombra', team: 'alpha', role: 'damage' },
{ hero: 'Winston', team: 'beta', role: 'tank' },
{ hero: 'Ana', team: 'alpha', role: 'support' },
{ hero: 'Tracer', team: 'beta', role: 'damage' },
],
},
{
number: 2,
name: 'Kings Row',
mode: 'Hybrid',
status: 'drafting',
heroBans: [{ hero: 'D.Va', team: 'alpha', role: 'tank' }],
},
{ number: 3, name: 'TBD', mode: 'Control', status: 'ready', heroBans: [] },
],
audit: [
{ id: 'a1', kind: 'coin_toss', summary: 'Ember Wolves won the toss', actorName: 'System', createdAt: daysFromNow(0, 20) },
{ id: 'a2', kind: 'map_selected', summary: 'Lijiang Tower selected', actorName: 'Nova', createdAt: daysFromNow(0, 20) },
{ id: 'a3', kind: 'result', summary: 'Map 1 won by Ember Wolves', actorName: 'Lemintare', createdAt: daysFromNow(0, 21) },
{ id: 'a4', kind: 'map_selected', summary: 'Kings Row selected', actorName: 'Hex', createdAt: daysFromNow(0, 21) },
{ id: 'a5', kind: 'hero_ban', summary: 'D.Va banned by Ember Wolves', actorName: 'Nova', createdAt: daysFromNow(0, 21) },
],
}
export const demoBracket: Bracket = {
id: 'bracket-1',
title: 'Summer Clash #4',
rounds: ['Semifinals', 'Grand Final'],
matches: [
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', scoreAlpha: 2, scoreBeta: 1, winner: 'alpha', status: 'completed', seriesId: 'series-2' },
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react'
export type Language = 'en' | 'ru'
export type LanguageContextValue = { language: Language; setLanguage: (language: Language) => void }
export const LanguageContext = createContext<LanguageContextValue | null>(null)
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) throw new Error('useLanguage must be used inside LanguageProvider')
return context.language
}

View File

@@ -0,0 +1,27 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { LanguageProvider, LanguageSelector } from './i18n'
afterEach(() => {
cleanup()
localStorage.clear()
})
describe('runtime language selection', () => {
it('switches UI text to Russian and persists the choice', async () => {
localStorage.setItem('mixmaker-language', 'en')
render(<LanguageProvider><LanguageSelector /><p>Your events</p></LanguageProvider>)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'ru' } })
await waitFor(() => expect(screen.getByText('Ваши события')).toBeInTheDocument())
expect(localStorage.getItem('mixmaker-language')).toBe('ru')
expect(document.documentElement.lang).toBe('ru')
})
it('restores English after switching back', async () => {
localStorage.setItem('mixmaker-language', 'ru')
render(<LanguageProvider><LanguageSelector /><p>Your events</p></LanguageProvider>)
await waitFor(() => expect(screen.getByText('Ваши события')).toBeInTheDocument())
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'en' } })
await waitFor(() => expect(screen.getByText('Your events')).toBeInTheDocument())
})
})

290
frontend/src/i18n.tsx Normal file
View File

@@ -0,0 +1,290 @@
import { useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { LanguageContext, type Language } from './i18n-context'
const translations: Record<string, string> = {
'Events': 'События',
'Live': 'Эфир',
'Bracket': 'Сетка',
'Admin': 'Админ',
'Profile': 'Профиль',
'Language': 'Язык',
'Primary navigation': 'Основная навигация',
'Mobile navigation': 'Мобильная навигация',
'Quick navigation': 'Быстрая навигация',
'Open profile': 'Открыть профиль',
'Sign out': 'Выйти',
'Toggle navigation': 'Открыть навигацию',
'Realtime connected': 'Онлайн-соединение установлено',
'Community scrims, organized': 'Организованные командные миксы',
'Ready up.': 'Приготовьтесь.',
'Play together.': 'Играйте вместе.',
'Balance teams, run drafts, and follow every map from one esports control room.': 'Балансируйте команды, проводите драфты и следите за каждой картой из единого киберспортивного центра.',
'Continue with Discord': 'Войти через Discord',
'Signing in creates a secure session. We never receive your Discord password.': 'Вход создаёт защищённую сессию. Мы не получаем ваш пароль Discord.',
'role-locked teams': 'команды по ролям',
'synchronized drafts': 'синхронные драфты',
'tournament series': 'турнирные серии',
'Loading match data…': 'Загрузка данных матча…',
'Syncing with the server.': 'Синхронизация с сервером.',
'Couldnt load this view': 'Не удалось загрузить экран',
'Your data is safe. Check your connection and try again.': 'Ваши данные в безопасности. Проверьте соединение и повторите попытку.',
'Try again': 'Повторить',
'Loading events…': 'Загрузка событий…',
'Scrim schedule': 'Расписание миксов',
'Your events': 'Ваши события',
'Join a mix, check the roster, and get match-ready.': 'Присоединяйтесь к миксу, проверяйте состав и готовьтесь к матчу.',
'Upcoming': 'Предстоящие',
'All': 'Все',
'Live now': 'Сейчас в эфире',
'Watch live': 'Смотреть',
'Coming up': 'Скоро',
'Filter events': 'Фильтровать события',
'New community nights will appear here.': 'Новые игровые вечера появятся здесь.',
'Going': 'Иду',
'Maybe': 'Возможно',
'going': 'идут',
'maybe': 'возможно',
'Cant go': 'Не иду',
'View event': 'Открыть событие',
'Loading event…': 'Загрузка события…',
'Event details': 'Детали события',
'Registration open': 'Регистрация открыта',
'Starts': 'Начало',
'Registration closes': 'Регистрация закрывается',
'Are you playing?': 'Вы участвуете?',
'Your latest response is sent to the organizer.': 'Ваш последний ответ отправлен организатору.',
'RSVP status': 'Статус участия',
'RSVP for': 'Статус участия для',
'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что',
'Attendance': 'Участие',
'No': 'Нет',
'Manage participants': 'Управлять участниками',
'Loading profile…': 'Загрузка профиля…',
'Player profile': 'Профиль игрока',
'Tune your ratings': 'Настройте рейтинги',
'These self-ratings help the server build fair, role-locked teams.': 'Эти самооценки помогают серверу создавать честные команды по ролям.',
'Set your competitive ranks': 'Укажите соревновательные ранги',
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
'Connected via Discord': 'Подключено через Discord',
'Role ratings': 'Рейтинги ролей',
'Use the full 1100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
'Competitive ranks': 'Соревновательные ранги',
'Select your current Overwatch rank for each role.': 'Выберите текущий ранг Overwatch для каждой роли.',
'Tank': 'Танк',
'Damage': 'Урон',
'Support': 'Поддержка',
'Space, pressure, and frontline': 'Пространство, давление и передовая',
'Eliminations and map control': 'Устранения и контроль карты',
'Sustain, utility, and tempo': 'Выживаемость, польза и темп',
'Only you can edit your ratings.': 'Только вы можете менять свои рейтинги.',
'Only you can edit your ranks and preferences.': 'Только вы можете менять свои ранги и предпочтения.',
'Save profile': 'Сохранить профиль',
'Saving…': 'Сохранение…',
'Saved': 'Сохранено',
'Preferred roles': 'Предпочитаемые роли',
'Choose every role you enjoy playing.': 'Выберите все роли, на которых вам нравится играть.',
'Preferred teammates': 'Предпочитаемые напарники',
'Choose up to 3 players. The balancer treats these as preferences, not guarantees.': 'Выберите до 3 игроков. Балансировщик учитывает это как пожелание, а не гарантию.',
'Loading admin control room…': 'Загрузка панели администратора…',
'Admin control room': 'Панель администратора',
'Create event': 'Создать событие',
'No events yet': 'Событий пока нет',
'Create the first community event.': 'Создайте первое событие сообщества.',
'Selected event': 'Выбранное событие',
'New event': 'Новое событие',
'Event setup': 'Настройки события',
'Participants': 'Участники',
'Balance teams': 'Баланс команд',
'Event settings': 'Настройки события',
'Scheduling and registration': 'Расписание и регистрация',
'Published': 'Опубликовано',
'Event name': 'Название события',
'Start time': 'Время начала',
'End time': 'Время окончания',
'Registration deadline': 'Крайний срок регистрации',
'Description': 'Описание',
'Times are sent to the server in UTC.': 'Время отправляется на сервер в UTC.',
'Save changes': 'Сохранить изменения',
'Server authority': 'Сервер — источник истины',
'Validation runs on the backend': 'Проверка выполняется на сервере',
'UTC scheduling': 'Расписание в UTC',
'Audited admin changes': 'Аудит действий администратора',
'Registration deadline rules': 'Правила срока регистрации',
'Loading participants…': 'Загрузка участников…',
'Participant responses': 'Ответы участников',
'Admin overrides are attributed and audited.': 'Изменения администратора подписываются и аудируются.',
'Not going': 'Не идёт',
'Preferred with': 'Предпочитает играть с',
'No preferred roles': 'Нет предпочитаемых ролей',
'Loading teams…': 'Загрузка команд…',
'Generate balance candidates': 'Создать варианты баланса',
'The backend uses confirmed Going players and returns three ranked alternatives.': 'Сервер использует подтверждённых участников и возвращает три ранжированных варианта.',
'Balancing…': 'Балансировка…',
'Generate on server': 'Создать на сервере',
'Server candidates': 'Варианты сервера',
'Lower imbalance score is better': 'Меньший дисбаланс лучше',
'Generate again': 'Создать заново',
'Computed by backend balancer': 'Рассчитано серверным балансировщиком',
'Imbalance score': 'Оценка дисбаланса',
'Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.': 'Меньшая оценка означает более близкие суммы команд и ролей. Сервер остаётся источником истины для составов.',
'Preference fit': 'Соответствие предпочтениям',
'Not assigned to a complete 5v5 roster': 'Не назначены в полный состав 5 на 5',
'The server will save these rosters and unlock captain assignment.': 'Сервер сохранит составы и откроет назначение капитанов.',
'Use this balance': 'Использовать баланс',
'Team captain': 'Капитан команды',
'Select from roster': 'Выбрать из состава',
'avg': 'среднее',
'Series control': 'Управление серией',
'Actions are validated and recorded by the server.': 'Действия проверяются и записываются сервером.',
'Spectator view': 'Режим зрителя',
'Current step': 'Текущий шаг',
'Current series score': 'Текущий счёт серии',
'Available draft choices': 'Доступные варианты драфта',
'Acting team': 'Активная команда',
'Map name': 'Название карты',
'Outcome': 'Результат',
'Draw': 'Ничья',
'Record result': 'Записать результат',
'Recording…': 'Запись…',
'Draft commands use dedicated backend endpoints and server-side validation.': 'Команды драфта используют отдельные серверные методы и серверную проверку.',
'Confirm action': 'Подтвердить действие',
'Best of 3': 'До двух побед',
'Captain not assigned': 'Капитан не назначен',
'Map timeline': 'Карты серии',
'First to 2 wins': 'До двух побед',
'No results recorded.': 'Результатов пока нет.',
'Match history': 'История матча',
'Auditable server actions': 'Аудируемые действия сервера',
'No actions yet.': 'Действий пока нет.',
'Spectator mode': 'Режим зрителя',
'Read-only live view · Updates automatically': 'Просмотр без редактирования · Обновляется автоматически',
'Connected': 'Подключено',
'Loading bracket…': 'Загрузка сетки…',
'Single elimination': 'Олимпийская система',
'Winners advance automatically when the server completes a series.': 'Победители проходят дальше автоматически после завершения серии сервером.',
'Open series': 'Открыть серию',
'Scroll horizontally to follow the bracket on smaller screens.': 'Прокручивайте сетку горизонтально на небольших экранах.',
'Restoring your session…': 'Восстановление сессии…',
'Series complete': 'Серия завершена',
'The tournament bracket has advanced.': 'Турнирная сетка обновлена.',
'An admin can record the authoritative map result.': 'Администратор может записать официальный результат карты.',
'Tournament series': 'Турнирная серия',
'Server ruleset': 'Серверный регламент',
'registration': 'регистрация',
'balancing': 'балансировка',
'live': 'эфир',
'completed': 'завершено',
'scheduled': 'запланировано',
'playing': 'игра',
'hero_draft': 'бан героев',
'map_draft': 'бан карт',
'coin_toss': 'жеребьёвка',
'Map': 'Карта',
}
function translate(value: string) {
const direct = translations[value]
if (direct) return direct
const rules: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
[/^(\d+) going$/, (m) => `${m[1]} идут`],
[/^(\d+) responses$/, (m) => `${m[1]} ответов`],
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],
[/^Reserve · (\d+)$/, (m) => `Резерв · ${m[1]}`],
[/^Team ([AB])$/, (m) => `Команда ${m[1]}`],
[/^Captain · (.+)$/, (m) => `Капитан · ${m[1]}`],
[/^Round (\d+)$/, (m) => `Раунд ${m[1]}`],
[/^Match (\d+)$/, (m) => `Матч ${m[1]}`],
[/^Ban (\d+)$/, (m) => `Бан ${m[1]}`],
[/^Record map (\d+)$/, (m) => `Записать карту ${m[1]}`],
[/^(.+) wins$/, (m) => `${m[1]} побеждает`],
[/^Team (.+)$/, (m) => `Команда ${m[1]}`],
[/^RSVP for (.+)$/, (m) => `Статус участия для ${m[1]}`],
[/^(\d+) preferred role assignments$/, (m) => `${m[1]} назначений на предпочитаемые роли`],
[/^(\d+) preferred teammate pairs? kept together$/, (m) => `${m[1]} предпочитаемых пар сохранено`],
[/^set by (.+)$/, (m) => `изменил ${m[1]}`],
]
for (const [pattern, formatter] of rules) {
const match = value.match(pattern)
if (match) return formatter(match)
}
return value
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('mixmaker-language')
if (saved === 'en' || saved === 'ru') return saved
return navigator.language.toLowerCase().startsWith('ru') ? 'ru' : 'en'
})
const originals = useRef(new WeakMap<Text, string>())
const attributeOriginals = useRef(new WeakMap<Element, Map<string, string>>())
useEffect(() => {
localStorage.setItem('mixmaker-language', language)
document.documentElement.lang = language
let applying = false
const localize = (root: Node) => {
if (applying) return
applying = true
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode() as Text | null
while (node) {
const parent = node.parentElement
if (parent && !['SCRIPT', 'STYLE'].includes(parent.tagName)) {
const current = node.data
const known = originals.current.get(node)
const knownRussian = known
? `${known.match(/^\s*/)?.[0] ?? ''}${translate(known.trim())}${known.match(/\s*$/)?.[0] ?? ''}`
: undefined
if (!known || (current !== known && current !== knownRussian)) originals.current.set(node, current)
const original = originals.current.get(node) ?? current
const trimmed = original.trim()
const replacement = language === 'ru' ? translate(trimmed) : trimmed
const leading = original.match(/^\s*/)?.[0] ?? ''
const trailing = original.match(/\s*$/)?.[0] ?? ''
const next = `${leading}${replacement}${trailing}`
if (node.data !== next) node.data = next
}
node = walker.nextNode() as Text | null
}
const elements = root instanceof Element ? [root, ...root.querySelectorAll('*')] : [...document.querySelectorAll('*')]
for (const element of elements) {
for (const attribute of ['aria-label', 'title', 'placeholder']) {
const value = element.getAttribute(attribute)
if (!value) continue
let stored = attributeOriginals.current.get(element)
if (!stored) { stored = new Map(); attributeOriginals.current.set(element, stored) }
if (!stored.has(attribute)) stored.set(attribute, value)
const original = stored.get(attribute) ?? value
element.setAttribute(attribute, language === 'ru' ? translate(original) : original)
}
}
applying = false
}
localize(document.body)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) localize(mutation.target)
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-label', 'title', 'placeholder'] })
return () => observer.disconnect()
}, [language])
return <LanguageContext.Provider value={{ language, setLanguage }}>{children}</LanguageContext.Provider>
}
export function LanguageSelector() {
const context = useContext(LanguageContext)
if (!context) return null
return (
<label className="language-selector">
<span className="sr-only">Language</span>
<select aria-label="Language" value={context.language} onChange={(event) => context.setLanguage(event.target.value as Language)}>
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
)
}

264
frontend/src/index.css Normal file
View File

@@ -0,0 +1,264 @@
@import "tailwindcss";
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f3f5f7;
background: #0b0d10;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #0b0d10;
--surface: #111419;
--surface-2: #171b21;
--surface-3: #1d222a;
--border: #272d36;
--muted: #89919d;
--orange: #ff7a1a;
--orange-soft: #2b1a10;
--blue: #38a9ff;
--blue-soft: #102435;
--green: #54dc95;
--yellow: #f8c552;
}
* { box-sizing: border-box; }
html { background: var(--bg); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 20% -10%, #21252d 0, transparent 28rem), var(--bg); }
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
svg { width: 1.1em; height: 1.1em; flex: none; }
h1, h2, h3, p { margin: 0; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }
.app-shell { min-height: 100vh; }
.topbar { height: 72px; padding: 0 max(22px, calc((100vw - 1240px) / 2)); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); background: rgba(11,13,16,.88); backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; }
.brand { display: inline-flex; align-items: center; gap: 10px; font-size: 15px; letter-spacing: .12em; font-weight: 600; }
.brand strong { color: var(--orange); }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #56301a; background: var(--orange-soft); color: var(--orange); clip-path: polygon(15% 0, 100% 0, 85% 100%, 0 100%); }
.desktop-nav { display: flex; align-self: stretch; gap: 8px; }
.desktop-nav a { position: relative; display: flex; align-items: center; gap: 7px; padding: 0 15px; color: var(--muted); font-size: 13px; font-weight: 650; }
.desktop-nav a:hover, .desktop-nav a.active { color: #fff; }
.desktop-nav a.active::after { content: ""; position: absolute; height: 2px; bottom: 0; left: 14px; right: 14px; background: var(--orange); box-shadow: 0 0 12px var(--orange); }
.account { display: flex; align-items: center; gap: 12px; }
.language-selector select { min-width: 54px; padding: 7px 8px; color: #d7dbe0; background: var(--surface-2); border-color: var(--border); font-size: 10px; font-weight: 850; cursor: pointer; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.avatar, .mini-avatar, .large-avatar { display: grid; place-items: center; border-radius: 50%; background: linear-gradient(135deg,#fa8d36,#703514); color: #fff; font-weight: 800; }
.avatar { width: 36px; height: 36px; font-size: 12px; border: 2px solid #393f48; }
.live-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: var(--green); box-shadow: 0 0 9px var(--green); }
.mobile-menu, .mobile-nav, .bottom-nav { display: none !important; }
main { min-height: calc(100vh - 72px); }
.page { max-width: 1240px; margin: 0 auto; padding: 44px 22px 80px; }
.page.narrow { max-width: 920px; }
.page-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 22px; margin-bottom: 30px; }
.page-header h1 { margin-top: 5px; font-size: clamp(30px, 4vw, 48px); line-height: 1.05; letter-spacing: -.045em; }
.page-header p { color: var(--muted); margin-top: 10px; max-width: 620px; }
.page-actions { display: flex; align-items: center; gap: 10px; }
.eyebrow { color: var(--orange); text-transform: uppercase; letter-spacing: .18em; font-size: 10px; font-weight: 850; }
.card { background: linear-gradient(150deg, rgba(25,29,35,.96), rgba(16,19,24,.98)); border: 1px solid var(--border); border-radius: 12px; }
.button { min-height: 44px; padding: 0 18px; border: 0; border-radius: 7px; display: inline-flex; justify-content: center; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; font-weight: 800; }
.button.primary { color: #110b06; background: var(--orange); box-shadow: 0 8px 28px rgba(255,122,26,.2); }
.button.primary:hover { background: #ff9142; }
.button.secondary { color: #e9ebed; background: var(--surface-2); border: 1px solid #343b45; }
.button.full { width: 100%; }
.icon-button { width: 40px; height: 40px; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; cursor: pointer; }
.badge { min-height: 24px; padding: 3px 8px; display: inline-flex; align-items: center; gap: 5px; border-radius: 99px; border: 1px solid #353b44; color: #abb2bd; background: #1a1e24; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
.badge svg { width: 12px; height: 12px; }
.badge-success { color: var(--green); border-color: #23583f; background: #10261c; }
.badge-warning { color: var(--yellow); border-color: #604b20; background: #281f0e; }
.badge-live { color: #ff9160; border-color: #69351f; background: #2a160e; }
.badge-blue { color: var(--blue); border-color: #22547b; background: #102435; }
.segmented { display: flex; padding: 3px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.segmented button { min-height: 34px; padding: 0 14px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer; }
.segmented button.active { color: #fff; background: var(--surface-3); }
.featured-event { min-height: 180px; padding: 28px 32px; display: grid; grid-template-columns: 1.5fr 1fr auto; align-items: center; gap: 28px; border: 1px solid #46301f; border-radius: 14px; background: linear-gradient(115deg, rgba(53,28,13,.92), rgba(18,20,25,.96) 52%, rgba(11,31,45,.75)), repeating-linear-gradient(135deg,transparent 0 20px,rgba(255,255,255,.015) 20px 21px); overflow: hidden; position: relative; }
.featured-event::after { content: ""; width: 240px; height: 240px; border-radius: 50%; border: 45px solid rgba(255,122,26,.06); position: absolute; right: 15%; top: -70px; pointer-events: none; }
.featured-event h2 { font-size: 26px; margin: 16px 0 6px; }
.featured-event p { color: var(--muted); font-size: 13px; }
.featured-score { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; text-align: center; }
.featured-score > span { font-size: 10px; letter-spacing: .1em; color: var(--muted); font-weight: 800; }
.featured-score strong { font-size: 42px; letter-spacing: -.06em; }
.featured-score i, .series-score i { color: #59616c; font-style: normal; padding: 0 8px; }
.section-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 28px 0 15px; }
.section-title h2 { font-size: 17px; }
.section-title p { color: var(--muted); font-size: 12px; margin-top: 4px; }
.event-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.event-card { display: grid; grid-template-columns: 95px 1fr; min-height: 210px; overflow: hidden; transition: border-color .2s, transform .2s; }
.event-card:hover { transform: translateY(-2px); border-color: #3d4550; }
.date-block { display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--border); background: rgba(255,255,255,.018); }
.date-block strong { font-size: 38px; line-height: 1; }
.date-block span { color: var(--orange); font-size: 11px; letter-spacing: .2em; margin-top: 7px; font-weight: 800; }
.event-card-body { padding: 22px; display: flex; flex-direction: column; }
.event-meta, .event-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.event-meta > span { color: var(--muted); font-size: 12px; }
.event-card h3 { font-size: 19px; margin-top: 18px; }
.event-card p { color: var(--muted); font-size: 12px; margin-top: 6px; }
.event-footer { margin-top: auto; padding-top: 18px; border-top: 1px solid var(--border); }
.event-footer span, .event-footer a { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
.event-footer span { color: var(--muted); }
.event-footer a { color: var(--orange); }
.detail-layout { display: grid; grid-template-columns: 1.5fr .8fr; gap: 18px; align-items: start; }
.event-hero, .roster-preview { padding: 28px; }
.event-time { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.event-time > svg { color: var(--orange); }
.event-time div { display: flex; flex-direction: column; gap: 4px; }
.event-time span, .captain-select span { color: var(--muted); font-size: 11px; }
.event-time strong { font-size: 14px; }
.divider { height: 1px; background: var(--border); margin: 28px 0; }
.event-hero > p { color: var(--muted); font-size: 12px; margin-top: 6px; }
.rsvp-control { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 20px; }
.rsvp-control button { min-height: 52px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--muted); display: flex; align-items: center; justify-content: center; gap: 7px; font-weight: 750; cursor: pointer; }
.rsvp-control button.active.going { color: var(--green); border-color: #2e7453; background: #10271c; }
.rsvp-control button.active.maybe { color: var(--yellow); border-color: #735d2a; background: #28200f; }
.rsvp-control button.active.not_going { color: #c9cdd2; border-color: #555e68; }
.save-note { margin-top: 14px; color: var(--green); font-size: 11px; display: flex; align-items: center; gap: 6px; }
.roster-preview .section-title { margin-top: 0; }
.attendance-bar { display: flex; height: 8px; gap: 3px; margin: 28px 0 18px; }
.attendance-bar span { border-radius: 10px; }
.attendance-bar span:nth-child(1) { width: 72%; background: var(--green); }
.attendance-bar span:nth-child(2) { width: 18%; background: var(--yellow); }
.attendance-bar span:nth-child(3) { width: 10%; background: #59616b; }
.attendance-stats { display: grid; gap: 12px; margin-bottom: 28px; }
.attendance-stats span { color: var(--muted); display: flex; align-items: center; gap: 7px; font-size: 12px; }
.attendance-stats strong { margin-left: auto; color: #fff; }
.attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; }
.attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
.profile-banner h2 { font-size: 21px; }.profile-banner p { color: var(--muted); margin: 3px 0 10px; font-size: 12px; }
.ratings-form { padding: 28px; }
.ratings-form > .section-title { margin-top: 0; }
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; }
.rating-row { display: grid; grid-template-columns: 1fr minmax(180px,230px); gap: 10px 24px; padding: 22px 0; border-top: 1px solid var(--border); }
.rating-row > div { display: flex; align-items: center; gap: 12px; }
.rating-row > div > span:last-child { display: flex; flex-direction: column; gap: 4px; }
.rating-row small { color: var(--muted); font-size: 11px; }
.role-icon { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
.role-tank .role-icon { color: #ad8cff; }.role-damage .role-icon { color: var(--orange); }.role-support .role-icon { color: var(--green); }
.rank-select { align-self: center; width: 100%; color: #fff; border-color: #3b424d; font-weight: 800; }
.preference-section { padding: 22px 0; border-top: 1px solid var(--border); }
.preference-section > div:first-child h2 { font-size: 16px; }
.preference-section > div:first-child p { color: var(--muted); font-size: 11px; margin-top: 5px; }
.preference-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.preference-chips button { min-height: 38px; padding: 0 16px; border: 1px solid var(--border); border-radius: 99px; background: var(--surface-2); color: var(--muted); font-size: 11px; font-weight: 800; cursor: pointer; }
.preference-chips button.active { color: var(--orange); border-color: var(--orange); background: var(--orange-soft); }
.teammate-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 14px; }
.teammate-grid label { min-height: 48px; padding: 8px 10px; display: grid; grid-template-columns: auto 28px 1fr; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); cursor: pointer; font-size: 10px; }
.teammate-grid label.selected { border-color: var(--blue); background: var(--blue-soft); }.teammate-grid label:has(input:disabled) { opacity: .45; cursor: not-allowed; }
.teammate-grid .mini-avatar { width: 26px; height: 26px; }
.form-footer { padding-top: 20px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.form-footer p { color: var(--muted); font-size: 11px; display: flex; align-items: center; gap: 6px; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid var(--border); margin-bottom: 20px; overflow-x: auto; }
.tabs button { padding: 13px 17px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-size: 12px; font-weight: 800; white-space: nowrap; cursor: pointer; }
.tabs button.active { color: #fff; border-color: var(--orange); }
.admin-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }
.settings-card, .admin-summary, .table-card { padding: 24px; }
.settings-card .section-title, .table-card .section-title { margin-top: 0; }
.field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; }
.settings-card label { display: flex; flex-direction: column; gap: 8px; color: #b6bcc4; font-size: 11px; font-weight: 700; margin-bottom: 16px; }
input, select, textarea { color: #e9ebed; background: #0e1115; border: 1px solid #303640; border-radius: 6px; padding: 11px 12px; }
textarea { min-height: 85px; resize: vertical; }
.admin-summary h2 { font-size: 17px; }
.readiness-score { margin: 25px 0; text-align: center; }.readiness-score strong { display: block; font-size: 48px; color: var(--orange); }.readiness-score span { color: var(--muted); font-size: 11px; }
.admin-summary ul { padding: 0; margin: 0 0 25px; list-style: none; display: grid; gap: 13px; }
.admin-summary li { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }.admin-summary li svg { color: var(--green); }
.count-pills { display: flex; gap: 6px; }
.participant-list { display: grid; }
.participant-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(130px,1fr) auto 130px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
.mini-avatar { width: 32px; height: 32px; font-size: 10px; }
.participant-name { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }.participant-name span { color: var(--muted); font-size: 10px; text-transform: capitalize; }.participant-name em { color: var(--yellow); font-style: normal; }
.mini-ratings { display: flex; gap: 5px; }.mini-ratings span { color: var(--muted); background: var(--surface-2); border-radius: 4px; padding: 4px 6px; font-size: 9px; }
.participant-row select { padding: 8px; font-size: 11px; }
.balance-layout { display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
.candidate-list .section-title { margin-top: 0; }
.candidate-card { width: 100%; min-height: 70px; display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; padding: 11px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); text-align: left; margin-bottom: 8px; cursor: pointer; }
.candidate-card.active { border-color: var(--orange); background: var(--orange-soft); }
.candidate-rank { color: #525a66; font-size: 16px; font-weight: 900; }.candidate-card.active .candidate-rank { color: var(--orange); }
.candidate-card > span:nth-child(2) { display: flex; flex-direction: column; gap: 4px; }.candidate-card strong { font-size: 12px; }.candidate-card small { color: var(--muted); font-size: 9px; }
.balance-score { padding: 20px; display: grid; grid-template-columns: 1.5fr .8fr .8fr 2fr; gap: 20px; align-items: center; margin-bottom: 12px; }
.balance-score > div { display: flex; flex-direction: column; gap: 4px; }.balance-score > div > span:not(.eyebrow) { color: var(--muted); font-size: 10px; }.balance-score > div > strong { font-size: 28px; }.balance-score > div:first-child strong { color: var(--green); font-size: 36px; }.balance-score small { font-size: 11px; color: var(--muted); }
.balance-score p { color: var(--muted); font-size: 11px; line-height: 1.6; }
.balance-explanations { display: flex; flex-direction: column; gap: 5px; }.balance-explanations > span { color: var(--muted); font-size: 10px; }.balance-explanations small { display: flex; align-items: center; gap: 5px; color: var(--green); font-size: 9px; }
.teams-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 12px; }
.team-card { overflow: hidden; }.team-card header { padding: 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.team-card header span { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .16em; }.team-card h3 { margin-top: 4px; }.team-card header > strong { font-size: 23px; }.team-card header > strong.team-rank { display: flex; flex-direction: column; align-items: flex-end; font-size: 13px; }.team-card header small { color: var(--muted); font-size: 9px; }
.team-alpha { --team: var(--orange); --team-bg: var(--orange-soft); }.team-beta { --team: var(--blue); --team-bg: var(--blue-soft); }
.team-card.team-alpha { border-top: 2px solid var(--orange); }.team-card.team-beta { border-top: 2px solid var(--blue); }
.team-members { padding: 8px 18px; }.team-members > div { min-height: 42px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 11px; }.team-members > div > span:last-child { color: var(--muted); }
.role-chip { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; font-size: 8px; font-weight: 900; color: #bdc3cb; background: #292e36; }
.role-chip.tank { color: #bc9eff; background: #241c38; }.role-chip.damage { color: #ff9b56; background: #352013; }.role-chip.support { color: #60dda0; background: #142c20; }
.captain-select { padding: 15px 18px 18px; display: flex; flex-direction: column; gap: 7px; }.captain-select span { display: flex; align-items: center; gap: 6px; }.captain-select select { padding: 9px; font-size: 11px; }
.reserve-bar { margin-top: 12px; padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.reserve-bar > div { display: flex; align-items: center; gap: 9px; }.reserve-bar span { display: flex; flex-direction: column; }.reserve-bar small { color: var(--muted); margin-top: 3px; font-size: 9px; }
.sticky-action { margin-top: 12px; padding: 14px 0; display: flex; align-items: center; justify-content: flex-end; gap: 18px; }.sticky-action p { color: var(--muted); font-size: 10px; }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
.series-score { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }.series-score > span { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 9px; }.series-score strong { font-size: 48px; line-height: 1.25; }
.live-layout { display: grid; grid-template-columns: 1fr 330px; gap: 16px; margin-top: 16px; align-items: start; }
.turn-card { border: 1px solid color-mix(in srgb,var(--team) 55%,#2b3038); border-top: 3px solid var(--team); border-radius: 12px; background: linear-gradient(155deg,var(--team-bg),var(--surface) 42%); overflow: hidden; }
.turn-header { padding: 26px; display: flex; align-items: start; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }.turn-header h2 { font-size: 27px; margin: 7px 0; }.turn-header p { color: var(--muted); font-size: 12px; }
.turn-team { text-align: right; display: flex; flex-direction: column; gap: 5px; }.turn-team span { color: var(--muted); font-size: 9px; text-transform: uppercase; }.turn-team strong { color: var(--team); font-size: 12px; }
.draft-options { padding: 22px; display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
.draft-options button { min-height: 74px; padding: 12px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border); border-radius: 7px; background: #12161b; cursor: pointer; }
.draft-options button > svg { color: transparent; }.draft-options button.selected { border-color: var(--team); background: var(--team-bg); }.draft-options button.selected > svg { color: var(--team); }
.draft-options button:disabled { opacity: .45; cursor: not-allowed; }.draft-options button small { grid-column: 2/-1; color: #e49765; font-size: 8px; }
.turn-footer { padding: 18px 22px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 16px; }.turn-footer p { color: var(--muted); font-size: 9px; display: flex; align-items: center; gap: 5px; }.team-action { background: var(--team); color: #081016; }.team-action:disabled { opacity: .4; cursor: not-allowed; }
.result-form { padding: 24px; display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 14px; }
.result-form label { display: flex; flex-direction: column; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; }
.error-note { color: #ff8f78 !important; font-size: 11px !important; margin-top: 10px; }
.empty-note { color: var(--muted); font-size: 11px; padding: 12px 0; }
.match-sidebar { display: grid; gap: 12px; }.map-timeline, .audit-log { padding: 18px; }.map-timeline .section-title, .audit-log .section-title { margin-top: 0; }
.map-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 9px; min-height: 54px; border-top: 1px solid var(--border); }.map-row > span { color: #515963; font-weight: 900; }.map-row > div { display: flex; flex-direction: column; gap: 4px; font-size: 11px; }.map-row small { color: var(--muted); font-size: 9px; text-transform: capitalize; }.map-row.drafting > span { color: var(--orange); }
.audit-log > div:not(.section-title) { display: grid; grid-template-columns: 10px 1fr; gap: 8px; padding: 9px 0; }.history-dot { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #4b525d; }.audit-log p { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }.audit-log small { color: var(--muted); }
.spectator-page { max-width: 1100px; }.spectator-focus { margin: 16px 0; padding: 32px; text-align: center; border: 1px solid #244f70; border-radius: 12px; background: radial-gradient(circle at 50% 0,rgba(32,127,192,.17),transparent 60%),var(--surface); }.spectator-focus h2 { font-size: 27px; margin: 8px 0; }.spectator-focus > p { color: var(--blue); font-size: 11px; }
.ban-display { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin-top: 28px; }.ban-display > div { min-height: 105px; padding: 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 7px; background: #0d1014; }.ban-display span,.ban-display small { color: var(--muted); font-size: 9px; }.ban-display strong { font-size: 17px; }.ban-display .pending-ban { border-color: var(--blue); background: var(--blue-soft); color: var(--blue); }
.spectator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bracket-scroll { min-height: 520px; display: flex; gap: 110px; padding: 30px 60px; overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }
.bracket-round { min-width: 300px; }.bracket-round > header { margin-bottom: 20px; }.bracket-round > header span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .15em; }.bracket-round > header h2 { margin-top: 4px; font-size: 18px; }
.bracket-matches { height: 400px; display: flex; flex-direction: column; justify-content: space-around; gap: 50px; }.bracket-match { width: 300px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); position: relative; box-shadow: 0 14px 35px rgba(0,0,0,.25); }.bracket-match::after { content: ""; width: 110px; height: 1px; position: absolute; right: -111px; top: 50%; background: #303640; }.bracket-round:last-child .bracket-match::after { display: none; }
.bracket-match.live { border-color: #78401f; box-shadow: 0 0 25px rgba(255,122,26,.08); }.match-label { padding: 8px 11px; display: flex; justify-content: space-between; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 9px; text-transform: uppercase; }.bracket-match > div:not(.match-label) { min-height: 38px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; font-size: 11px; }.bracket-match > div.winner { color: var(--green); }.bracket-match > a { padding: 9px 12px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; color: var(--orange); font-size: 9px; }
.bracket-help { display: flex; justify-content: center; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; margin-top: 12px; }
.state-card { min-height: 350px; margin: 30px auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center; }.state-card > svg { width: 32px; height: 32px; color: var(--orange); }.state-card h2 { color: #fff; }.state-card p { font-size: 12px; }
.spin { animation: spin 1.5s linear infinite; }
.login-page { min-height: 100vh; display: grid; place-items: center; overflow: hidden; position: relative; background: linear-gradient(rgba(7,9,12,.85),rgba(7,9,12,.95)),repeating-linear-gradient(120deg,transparent 0 44px,rgba(255,255,255,.018) 44px 45px); }
.login-card { width: min(460px,calc(100% - 36px)); padding: 45px; z-index: 1; text-align: center; border: 1px solid var(--border); border-top: 2px solid var(--orange); border-radius: 14px; background: rgba(17,20,25,.9); backdrop-filter: blur(20px); box-shadow: 0 40px 90px rgba(0,0,0,.45); }
.login-brand-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 45px; }.login-brand-row .brand { margin-bottom: 0; }.login-card .eyebrow { display: block; }.login-card h1 { font-size: 48px; line-height: .98; letter-spacing: -.06em; margin: 13px 0 18px; }.login-card h1 span { color: var(--orange); }.login-card > p { color: var(--muted); line-height: 1.6; font-size: 13px; }.discord-button { width: 100%; margin: 28px 0 15px; background: #5865f2; color: #fff; }.login-card > small { color: #606874; font-size: 9px; }
.login-orb { position: absolute; width: 420px; height: 420px; border-radius: 50%; filter: blur(120px); opacity: .16; }.login-orb-orange { background: var(--orange); left: -150px; top: -140px; }.login-orb-blue { background: var(--blue); right: -150px; bottom: -140px; }
.login-stats { position: absolute; bottom: 28px; display: flex; gap: 40px; color: #59616c; font-size: 9px; text-transform: uppercase; letter-spacing: .1em; }.login-stats strong { color: #b9bfc7; margin-right: 5px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 900px) {
.desktop-nav { display: none; }.mobile-menu { display: grid !important; }.account > .live-dot { display: none; }
.mobile-nav { display: grid !important; position: fixed; z-index: 30; top: 70px; right: 12px; width: 220px; padding: 8px; border: 1px solid var(--border); border-radius: 9px; background: #13171c; box-shadow: 0 25px 70px rgba(0,0,0,.5); }
.mobile-nav a { padding: 13px; display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
.featured-event { grid-template-columns: 1fr auto; }.featured-event > div:first-child { grid-column: 1/-1; }.event-grid { grid-template-columns: 1fr; }
.detail-layout,.admin-grid,.live-layout { grid-template-columns: 1fr; }.balance-layout { grid-template-columns: 1fr; }.candidate-list { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }.candidate-list .section-title { grid-column: 1/-1; }.candidate-card { margin: 0; }
.balance-score { grid-template-columns: repeat(3,1fr); }.balance-score p { grid-column: 1/-1; }.turn-header { flex-direction: column; }.turn-team { text-align: left; }
}
@media (max-width: 640px) {
.topbar { height: 62px; padding: 0 16px; }.page { padding: 28px 14px 92px; }.page-header { align-items: flex-start; flex-direction: column; margin-bottom: 22px; }.page-header h1 { font-size: 34px; }.page-actions { width: 100%; }
.bottom-nav { display: grid !important; position: fixed; z-index: 20; bottom: 0; left: 0; right: 0; grid-template-columns: repeat(4,1fr); padding: 6px 8px max(6px,env(safe-area-inset-bottom)); border-top: 1px solid var(--border); background: rgba(13,16,20,.96); backdrop-filter: blur(16px); }
.bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); }
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }
.ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; }
.bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }
.login-card { padding: 35px 24px; }.login-card h1 { font-size: 39px; }.login-stats { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { LanguageProvider } from './i18n.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { RANK_ORDINALS, ordinalToRank, rankLabel, rankOptions } from './ranks'
describe('Overwatch competitive rank utilities', () => {
it('contains all 40 ordered rank values', () => {
expect(RANK_ORDINALS).toHaveLength(40)
expect(rankLabel(1, 'en')).toBe('Bronze 5')
expect(rankLabel(40, 'en')).toBe('Champion 1')
})
it('maps tier boundaries correctly', () => {
expect(ordinalToRank(5)).toMatchObject({ tier: 'Bronze', division: 1 })
expect(ordinalToRank(6)).toMatchObject({ tier: 'Silver', division: 5 })
expect(ordinalToRank(35)).toMatchObject({ tier: 'Grandmaster', division: 1 })
expect(ordinalToRank(36)).toMatchObject({ tier: 'Champion', division: 5 })
})
it('renders localized options highest rank first', () => {
const options = rankOptions('ru')
expect(options[0]).toEqual({ value: 40, label: 'Чемпион 1' })
expect(options.at(-1)).toEqual({ value: 1, label: 'Бронза 5' })
})
})

50
frontend/src/ranks.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Language } from './i18n-context'
export type RankOrdinal =
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
| 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20
| 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30
| 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40
export const RANK_ORDINALS = Array.from({ length: 40 }, (_, index) => index + 1) as RankOrdinal[]
const tiers = [
{ en: 'Bronze', ru: 'Бронза' },
{ en: 'Silver', ru: 'Серебро' },
{ en: 'Gold', ru: 'Золото' },
{ en: 'Platinum', ru: 'Платина' },
{ en: 'Diamond', ru: 'Алмаз' },
{ en: 'Master', ru: 'Мастер' },
{ en: 'Grandmaster', ru: 'Грандмастер' },
{ en: 'Champion', ru: 'Чемпион' },
] as const
export interface CompetitiveRank {
ordinal: RankOrdinal
tier: (typeof tiers)[number]['en']
division: 1 | 2 | 3 | 4 | 5
}
export function toRankOrdinal(value: number): RankOrdinal {
return Math.min(40, Math.max(1, Math.round(value))) as RankOrdinal
}
export function ordinalToRank(value: number): CompetitiveRank {
const ordinal = toRankOrdinal(value)
const tierIndex = Math.floor((ordinal - 1) / 5)
const division = (5 - ((ordinal - 1) % 5)) as CompetitiveRank['division']
return { ordinal, tier: tiers[tierIndex].en, division }
}
export function rankLabel(value: number, language: Language) {
const rank = ordinalToRank(value)
const tier = tiers.find((item) => item.en === rank.tier)!
return `${tier[language]} ${rank.division}`
}
export function rankOptions(language: Language) {
return [...RANK_ORDINALS].reverse().map((ordinal) => ({
value: ordinal,
label: rankLabel(ordinal, language),
}))
}

View File

@@ -0,0 +1,6 @@
import '@testing-library/jest-dom/vitest'
Object.defineProperty(window, 'scrollTo', {
value: () => undefined,
writable: true,
})

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: process.env.VITE_DEV_API_TARGET ?? 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})

View File

@@ -2,7 +2,7 @@
## Текущее состояние ## Текущее состояние
Проект начинается с нуля. Сформированы исходный контекст, предметная модель, архитектурное направление и предварительная дорожная карта. Собран и проверен full-stack MVP: Go API с PostgreSQL, Discord OAuth и серверными сессиями, React-интерфейс, календарь и RSVP, административные сценарии, preference-aware балансировка 1/2/2, капитаны, Bo3-драфты и результаты, single-elimination сетка, SSE и RU/EN локализация. Добавлены Docker Compose для локальной разработки и Coolify, миграции, OpenAPI-контракт и CI.
## Подтверждённые требования ## Подтверждённые требования
@@ -10,6 +10,8 @@
- backend на Go с Clean Architecture; - backend на Go с Clean Architecture;
- современный frontend на фреймворке; - современный frontend на фреймворке;
- Discord OAuth и самостоятельное заполнение игроком рейтингов Tank, Damage и Support; - Discord OAuth и самостоятельное заполнение игроком рейтингов Tank, Damage и Support;
- выбор игроком предпочитаемых ролей и до трёх желаемых союзников;
- переключение всего интерфейса между RU и EN;
- календарь запланированных миксов со статусами Going / Maybe / NotGoing; - календарь запланированных миксов со статусами Going / Maybe / NotGoing;
- административная панель управления событиями, игроками и регистрациями; - административная панель управления событиями, игроками и регистрациями;
- возможность Admin отметить участие за другого игрока; - возможность Admin отметить участие за другого игрока;
@@ -49,8 +51,9 @@
## Принятые решения для MVP ## Принятые решения для MVP
- игрок входит через Discord; - игрок входит через Discord;
- игрок самостоятельно указывает рейтинг 1100 для каждой роли; - игрок самостоятельно указывает Overwatch-ранг от Bronze 5 до Champion 1 для каждой роли;
- балансировщик автоматически назначает роли и собирает команды 1/2/2; - балансировщик автоматически назначает роли и собирает команды 1/2/2;
- предпочтения ролей и союзников учитываются как мягкие штрафы после требований баланса;
- участники сверх полного состава остаются в резерве; - участники сверх полного состава остаются в резерве;
- первая турнирная сетка — single elimination; - первая турнирная сетка — single elimination;
- игрок отмечается на микс через календарь; - игрок отмечается на микс через календарь;
@@ -63,6 +66,6 @@
## Ближайший следующий шаг ## Ближайший следующий шаг
Создать full-stack каркас и первый вертикальный сценарий: Discord-вход → профиль с рейтингами → календарь → отметка участия → админка регистраций → список участников события. Развернуть тестовый экземпляр через Coolify, настроить Discord OAuth и выполнить приёмочное тестирование полного сценария на реальных аккаунтах и PostgreSQL.
Открытое продуктовое решение, которое не блокирует каркас: как выбирается Hybrid/Escort для второй карты — заранее организатором, случайно или отдельным действием команд. Открытое продуктовое решение, которое не блокирует каркас: как выбирается Hybrid/Escort для второй карты — заранее организатором, случайно или отдельным действием команд.

View File

@@ -42,6 +42,7 @@ backend/
- React + TypeScript; - React + TypeScript;
- Vite; - Vite;
- TanStack Router и TanStack Query; - TanStack Router и TanStack Query;
- собственный типизированный слой локализации RU/EN с сохранением языка в localStorage;
- Tailwind CSS; - Tailwind CSS;
- shadcn/ui как основа компонентов; - shadcn/ui как основа компонентов;
- Framer Motion для умеренных анимаций; - Framer Motion для умеренных анимаций;
@@ -63,7 +64,7 @@ frontend/src/
## Границы модулей ## Границы модулей
- Players: профили, рейтинги и предпочтения ролей; - Players: профили, рейтинги, предпочтения ролей и желаемых союзников;
- Auth: Discord OAuth, сессии и роли доступа; - Auth: Discord OAuth, сессии и роли доступа;
- Admin: управление игроками, регистрациями, событиями и капитанами; - Admin: управление игроками, регистрациями, событиями и капитанами;
- Events: календарь, регистрации участников и состояние игрового вечера; - Events: календарь, регистрации участников и состояние игрового вечера;
@@ -77,7 +78,9 @@ frontend/src/
### Балансировщик ### Балансировщик
В MVP — детерминированный оптимизатор с весовой функцией качества и ограниченным поиском вариантов. Алгоритм располагается в доменном слое и не зависит от БД. Позже его можно заменить на constraint solver без изменения API сценария. В MVP — детерминированный оптимизатор с весовой функцией качества и ограниченным поиском вариантов. Overwatch-ранги Bronze 5 → Champion 1 отображаются в ordinal 140 только для внутренних вычислений. Жёсткие ограничения обеспечивают состав 1/2/2 и уникальность игроков. Мягкие штрафы учитывают общий и ролевой разброс рейтинга, назначение вне предпочитаемой роли и разделение желаемых союзников. После построения нескольких начальных вариантов выполняется локальный поиск обменами игроков одной роли.
Алгоритм располагается в доменном слое и не зависит от БД. При росте сообщества его можно заменить на CP-SAT/MILP solver без изменения API сценария.
### Драфт как конечный автомат ### Драфт как конечный автомат

View File

@@ -4,7 +4,9 @@
### Player ### Player
Игрок связан с Discord-аккаунтом и имеет отображаемое имя и собственный рейтинг для каждой роли: Tank, Damage, Support. Игрок редактирует свои рейтинги самостоятельно; организатор может видеть время последнего изменения. Дополнительно можно хранить предпочитаемые роли, BattleTag и историю участия. Игрок связан с Discord-аккаунтом и имеет отображаемое имя и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 до Champion 1: Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster, Champion, каждый с дивизионами 5 → 1. Для вычислений значения преобразуются в ordinal 140.
Игрок редактирует ранги самостоятельно; Admin может видеть время последнего изменения. Игрок выбирает любое число предпочитаемых ролей и до трёх желаемых союзников. Предпочтения являются мягкими пожеланиями, а не гарантией состава.
### Account ### Account
@@ -86,12 +88,15 @@
- разницу средних рейтингов по ролям; - разницу средних рейтингов по ролям;
- корректность состава 1 Tank / 2 Damage / 2 Support; - корректность состава 1 Tank / 2 Damage / 2 Support;
- предпочтения игроков; - назначение игроков на предпочитаемые роли;
- попадание выбранных игроком союзников в одну команду;
- запрет повторения составов при регулярных играх; - запрет повторения составов при регулярных играх;
- заранее заданные пары или ограничения «вместе/раздельно». - заранее заданные пары или ограничения «вместе/раздельно».
Алгоритм должен возвращать не только составы, но и понятную оценку качества и причины оставшегося дисбаланса. Алгоритм должен возвращать не только составы, но и понятную оценку качества и причины оставшегося дисбаланса.
Жёсткие ограничения 1/2/2 и уникальности игроков всегда важнее предпочтений. Разница рейтингов имеет больший вес, чем пожелания. В MVP используется детерминированная эвристика: несколько начальных назначений, взвешенная целевая функция и локальные обмены игроков одной роли. Архитектура допускает замену на CP-SAT/MILP solver.
## Доменные события ## Доменные события
- EventCreated; - EventCreated;

View File

@@ -7,10 +7,12 @@ Mixmaker — веб-приложение для организации люби
## Основные задачи ## Основные задачи
- регистрация участников и оценка их силы по ролям; - регистрация участников и оценка их силы по ролям;
- вход через Discord и самостоятельное редактирование игроком своих рейтингов; - вход через Discord и самостоятельный выбор ранга Bronze 5 → Champion 1 для каждой роли;
- выбор предпочитаемых ролей и до трёх желаемых союзников;
- переключение интерфейса между русским и английским языком;
- календарь запланированных миксов и отметки об участии; - календарь запланированных миксов и отметки об участии;
- административная панель для управления событиями, участниками и их отметками; - административная панель для управления событиями, участниками и их отметками;
- автоматическая балансировка нескольких команд; - автоматическая балансировка нескольких команд с мягким учётом предпочтений;
- назначение капитанов сформированных команд; - назначение капитанов сформированных команд;
- проведение жеребьёвки; - проведение жеребьёвки;
- пошаговые баны карт и героев с автоматической проверкой правил; - пошаговые баны карт и героев с автоматической проверкой правил;

View File

@@ -1,5 +1,7 @@
# Дорожная карта # Дорожная карта
Статус на 19 июля 2026: технический MVP этапов 04 реализован. Следующий контрольный рубеж — развёртывание в Coolify и приёмочное тестирование с реальными Discord-аккаунтами; улучшения этапа 5 остаются будущей работой.
## Этап 0 — уточнение продукта ## Этап 0 — уточнение продукта
- согласовать способ оценки игроков и рейтинг по ролям; - согласовать способ оценки игроков и рейтинг по ролям;
@@ -21,11 +23,13 @@
- добавить роли Admin/Player и безопасное начальное назначение Admin по Discord ID; - добавить роли Admin/Player и безопасное начальное назначение Admin по Discord ID;
- зафиксировать OpenAPI-контракт; - зафиксировать OpenAPI-контракт;
- реализовать базовую навигацию и дизайн-систему; - реализовать базовую навигацию и дизайн-систему;
- добавить переключение RU/EN и локализовать все пользовательские строки;
- добавить автоматические проверки и тесты. - добавить автоматические проверки и тесты.
## Этап 2 — игроки и балансировка ## Этап 2 — игроки и балансировка
- самостоятельное заполнение профиля и рейтингов по ролям; - самостоятельное заполнение профиля и рейтингов по ролям;
- выбор предпочитаемых ролей и до трёх желаемых союзников;
- календарь будущих миксов; - календарь будущих миксов;
- статусы участия Going / Maybe / NotGoing; - статусы участия Going / Maybe / NotGoing;
- админку для событий, игроков и регистраций; - админку для событий, игроков и регистраций;
@@ -34,6 +38,7 @@
- назначение доступных ролей; - назначение доступных ролей;
- генерация нескольких вариантов команд; - генерация нескольких вариантов команд;
- оценка качества баланса; - оценка качества баланса;
- мягкий учёт ролевых и командных предпочтений в целевой функции;
- ручная перестановка игроков с пересчётом показателей. - ручная перестановка игроков с пересчётом показателей.
- назначение и переназначение капитана каждой команды. - назначение и переназначение капитана каждой команды.

735
openapi/openapi.yaml Normal file
View File

@@ -0,0 +1,735 @@
openapi: 3.1.0
info:
title: Mixmaker API
version: 0.1.0
description: API for Overwatch community scrims.
servers:
- url: /
security:
- cookieAuth: []
tags:
- name: Auth
- name: Players
- name: Events
- name: Teams
- name: Series
- name: Tournaments
paths:
/healthz:
get:
security: []
operationId: health
responses:
"200":
description: Healthy
/readyz:
get:
security: []
operationId: ready
responses:
"200":
description: Ready
"503":
description: Not ready
/api/auth/discord:
get:
security: []
tags: [Auth]
operationId: startDiscordLogin
responses:
"302":
description: Redirect to Discord
/api/auth/discord/callback:
get:
security: []
tags: [Auth]
operationId: finishDiscordLogin
parameters:
- name: code
in: query
required: true
schema:
type: string
- name: state
in: query
required: true
schema:
type: string
responses:
"302":
description: Session created
/api/auth/me:
get:
tags: [Auth]
operationId: getCurrentAccount
responses:
"200":
description: Current account
content:
application/json:
schema:
$ref: "#/components/schemas/Session"
"401":
$ref: "#/components/responses/Unauthorized"
/api/auth/logout:
post:
tags: [Auth]
operationId: logout
responses:
"204":
description: Logged out
/api/me/player:
patch:
tags: [Players]
operationId: updateMyPlayer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PlayerUpdate"
responses:
"200":
description: Updated profile
content:
application/json:
schema:
$ref: "#/components/schemas/Player"
"422":
$ref: "#/components/responses/ValidationError"
/api/players:
get:
tags: [Players]
operationId: listPlayers
responses:
"200":
description: Players
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Player"
/api/events:
get:
tags: [Events]
operationId: listEvents
parameters:
- name: from
in: query
schema:
type: string
format: date-time
responses:
"200":
description: Events
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Event"
post:
tags: [Events]
operationId: createEvent
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EventInput"
responses:
"201":
description: Event created
content:
application/json:
schema:
$ref: "#/components/schemas/Event"
"403":
$ref: "#/components/responses/Forbidden"
/api/events/{eventId}:
get:
tags: [Events]
operationId: getEvent
parameters:
- $ref: "#/components/parameters/EventId"
responses:
"200":
description: Event details
content:
application/json:
schema:
$ref: "#/components/schemas/Event"
"404":
$ref: "#/components/responses/NotFound"
put:
tags: [Events]
operationId: updateEvent
parameters:
- $ref: "#/components/parameters/EventId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EventInput"
responses:
"200":
description: Event updated
content:
application/json:
schema:
$ref: "#/components/schemas/Event"
/api/events/{eventId}/rsvps:
get:
tags: [Events]
operationId: listRegistrations
parameters:
- $ref: "#/components/parameters/EventId"
responses:
"200":
description: Registrations
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/EventRegistration"
/api/events/{eventId}/rsvps/{playerId}:
put:
tags: [Events]
operationId: adminSetRegistration
parameters:
- $ref: "#/components/parameters/EventId"
- $ref: "#/components/parameters/PlayerId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RegistrationInput"
responses:
"200":
description: Registration updated by admin
content:
application/json:
schema:
$ref: "#/components/schemas/EventRegistration"
"403":
$ref: "#/components/responses/Forbidden"
/api/events/{eventId}/balance:
post:
tags: [Teams]
operationId: generateBalanceCandidates
parameters:
- $ref: "#/components/parameters/EventId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
seed:
type: integer
format: int64
responses:
"200":
description: Three best candidates
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/BalanceCandidate"
/api/teams/{teamId}/captain:
put:
tags: [Teams]
operationId: assignCaptain
parameters:
- $ref: "#/components/parameters/TeamId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [playerId]
properties:
playerId:
type: string
format: uuid
responses:
"200":
description: Captain assigned
content:
application/json:
schema:
$ref: "#/components/schemas/Team"
"422":
$ref: "#/components/responses/ValidationError"
/api/coin-toss:
post:
tags: [Series]
operationId: tossCoin
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [teamAId, teamBId, seed, eventId, actingTeamId]
properties:
teamAId: { type: string }
teamBId: { type: string }
seed: { type: string }
eventId: { type: string }
actingTeamId: { type: string }
responses:
"200":
description: Coin toss result
content:
application/json:
schema:
type: object
/api/drafts/{seriesId}/map-bans:
post:
tags: [Series]
operationId: banMap
parameters:
- $ref: "#/components/parameters/SeriesId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [teamId, map, eventId, version]
properties:
teamId: { type: string }
map: { type: string }
eventId: { type: string }
version:
type: integer
responses:
"200":
description: Updated series
content:
application/json:
schema:
$ref: "#/components/schemas/Series"
"409":
$ref: "#/components/responses/Conflict"
/api/drafts/{seriesId}/hero-bans:
post:
tags: [Series]
operationId: banHero
parameters:
- $ref: "#/components/parameters/SeriesId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [teamId, hero, eventId, version]
properties:
teamId: { type: string }
hero: { type: string }
eventId: { type: string }
version:
type: integer
responses:
"200":
description: Updated series
content:
application/json:
schema:
$ref: "#/components/schemas/Series"
"409":
$ref: "#/components/responses/Conflict"
/api/series/{seriesId}/results:
post:
tags: [Series]
operationId: recordMapResult
parameters:
- $ref: "#/components/parameters/SeriesId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MapResultInput"
responses:
"200":
description: Result recorded
content:
application/json:
schema:
$ref: "#/components/schemas/Series"
/api/events/stream:
get:
tags: [Series]
operationId: subscribeSeries
parameters:
- name: topic
in: query
schema:
type: string
responses:
"200":
description: Server-sent event stream
content:
text/event-stream:
schema:
type: string
/api/tournaments/{tournamentId}:
get:
tags: [Tournaments]
operationId: getTournament
parameters:
- $ref: "#/components/parameters/TournamentId"
responses:
"200":
description: Tournament bracket
content:
application/json:
schema:
$ref: "#/components/schemas/Tournament"
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: mixmaker_session
parameters:
EventId:
name: eventId
in: path
required: true
schema:
type: string
format: uuid
PlayerId:
name: playerId
in: path
required: true
schema:
type: string
format: uuid
TeamId:
name: teamId
in: path
required: true
schema:
type: string
format: uuid
SeriesId:
name: seriesId
in: path
required: true
schema:
type: string
format: uuid
TournamentId:
name: tournamentId
in: path
required: true
schema:
type: string
format: uuid
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
Forbidden:
description: Insufficient permission
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
Conflict:
description: State version conflict
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
ValidationError:
description: Invalid command
content:
application/json:
schema:
$ref: "#/components/schemas/Problem"
schemas:
Session:
type: object
required: [account, player]
properties:
account:
$ref: "#/components/schemas/Account"
player:
$ref: "#/components/schemas/Player"
Account:
type: object
required: [id, discordId, username, role, createdAt]
properties:
id:
type: string
format: uuid
discordId:
type: string
username:
type: string
avatarUrl:
type: [string, "null"]
format: uri
role:
type: string
enum: [player, admin]
createdAt:
type: string
format: date-time
Ratings:
type: object
required: [tank, damage, support]
properties:
tank:
type: integer
minimum: 1
maximum: 40
description: Bronze 5 = 1, Champion 1 = 40
damage:
type: integer
minimum: 1
maximum: 40
description: Bronze 5 = 1, Champion 1 = 40
support:
type: integer
minimum: 1
maximum: 40
description: Bronze 5 = 1, Champion 1 = 40
Player:
type: object
required: [id, accountId, displayName, ratings, preferredRoles, preferredPlayerIds, updatedAt]
properties:
id:
type: string
format: uuid
accountId:
type: string
format: uuid
displayName:
type: string
ratings:
$ref: "#/components/schemas/Ratings"
preferredRoles:
type: array
items:
type: string
enum: [Tank, Damage, Support]
preferredPlayerIds:
type: array
maxItems: 3
items:
type: string
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
PlayerUpdate:
type: object
required: [ratings]
properties:
displayName:
type: string
ratings:
$ref: "#/components/schemas/Ratings"
preferredRoles:
type: array
items:
type: string
enum: [Tank, Damage, Support]
preferredPlayerIds:
type: array
maxItems: 3
items:
type: string
EventInput:
type: object
required: [name, startsAt, endsAt, registrationDeadline]
properties:
name:
type: string
minLength: 1
description:
type: string
startsAt:
type: string
format: date-time
endsAt:
type: string
format: date-time
registrationDeadline:
type: string
format: date-time
Event:
allOf:
- $ref: "#/components/schemas/EventInput"
- type: object
required: [id, createdBy, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
createdBy:
type: string
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
RegistrationInput:
type: object
required: [status]
properties:
status:
type: string
enum: [Going, Maybe, NotGoing]
EventRegistration:
type: object
required: [eventId, playerId, status, actorAccountId, source, updatedAt]
properties:
eventId:
type: string
format: uuid
playerId:
type: string
format: uuid
status:
type: string
enum: [Going, Maybe, NotGoing]
actorAccountId:
type: string
format: uuid
source:
type: string
enum: [player, admin]
updatedAt:
type: string
format: date-time
TeamSlot:
type: object
required: [playerId, role, rating]
properties:
playerId:
type: string
role:
type: string
enum: [Tank, Damage, Support]
rating:
type: integer
Team:
type: object
required: [id, eventId, name, captainPlayerId, slots]
properties:
id:
type: string
eventId:
type: string
name:
type: string
captainPlayerId:
type: string
slots:
type: array
minItems: 5
maxItems: 5
items:
$ref: "#/components/schemas/TeamSlot"
BalanceCandidate:
type: object
required: [score, teams, reserve]
properties:
score:
type: integer
teams:
type: array
items:
$ref: "#/components/schemas/Team"
reserve:
type: array
items:
type: string
MapResultInput:
type: object
required: [mapName, outcome, expectedVersion]
properties:
mapName:
type: string
outcome:
type: string
enum: [TeamAWin, TeamBWin, Draw]
expectedVersion:
type: integer
Series:
type: object
required: [id, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, results, version]
properties:
id:
type: string
tournamentId:
type: string
teamAId:
type: string
teamBId:
type: string
winnerTeamId:
type: string
bestOf:
type: integer
results:
type: array
items:
type: object
version:
type: integer
Tournament:
type: object
required: [id, eventId, name, winnerTeamId, teamIds, rounds]
properties:
id:
type: string
eventId:
type: string
name:
type: string
winnerTeamId:
type: string
teamIds:
type: array
items:
type: string
rounds:
type: array
items:
type: array
items:
$ref: "#/components/schemas/Series"
Problem:
type: object
required: [error]
properties:
error:
type: string