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