Add player profiles and community settings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,6 +65,9 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
|
|||||||
api.Get("/api/me", s.me)
|
api.Get("/api/me", s.me)
|
||||||
api.Patch("/api/me/player", s.updateProfile)
|
api.Patch("/api/me/player", s.updateProfile)
|
||||||
api.Get("/api/players", s.players)
|
api.Get("/api/players", s.players)
|
||||||
|
api.Get("/api/players/{playerID}", s.playerProfile)
|
||||||
|
api.Get("/api/community/settings", s.communitySettings)
|
||||||
|
api.Put("/api/community/settings", s.updateCommunitySettings)
|
||||||
api.Get("/api/accounts", s.accounts)
|
api.Get("/api/accounts", s.accounts)
|
||||||
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
|
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
|
||||||
api.Get("/api/events", s.events)
|
api.Get("/api/events", s.events)
|
||||||
@@ -246,7 +249,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
var in struct {
|
var in struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName *string `json:"displayName"`
|
||||||
|
BattleTag *string `json:"battleTag"`
|
||||||
Ratings domain.Ratings `json:"ratings"`
|
Ratings domain.Ratings `json:"ratings"`
|
||||||
PreferredRoles []domain.Role `json:"preferredRoles"`
|
PreferredRoles []domain.Role `json:"preferredRoles"`
|
||||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||||
@@ -256,7 +260,10 @@ func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := who(r)
|
id := who(r)
|
||||||
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
|
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, application.UpdateOwnProfileInput{
|
||||||
|
DisplayName: in.DisplayName, BattleTag: in.BattleTag, Ratings: in.Ratings,
|
||||||
|
PreferredRoles: in.PreferredRoles, PreferredPlayerIDs: in.PreferredPlayerIDs, AvoidedPlayerIDs: in.AvoidedPlayerIDs,
|
||||||
|
})
|
||||||
respond(w, out, err, http.StatusOK)
|
respond(w, out, err, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +272,27 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
|
|||||||
respond(w, out, err, 200)
|
respond(w, out, err, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) playerProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out, err := s.service.GetPlayerProfile(r.Context(), chi.URLParam(r, "playerID"))
|
||||||
|
respond(w, out, err, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) communitySettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out, err := s.service.GetCommunitySettings(r.Context())
|
||||||
|
respond(w, out, err, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateCommunitySettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
DiscordInviteURL string `json:"discordInviteUrl"`
|
||||||
|
}
|
||||||
|
if !decode(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := s.service.UpdateCommunitySettings(r.Context(), who(r).account, input.DiscordInviteURL)
|
||||||
|
respond(w, out, err, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
|
||||||
out, err := s.service.ListAccounts(r.Context(), who(r).account)
|
out, err := s.service.ListAccounts(r.Context(), who(r).account)
|
||||||
respond(w, out, err, http.StatusOK)
|
respond(w, out, err, http.StatusOK)
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mixmaker/backend/internal/application"
|
||||||
|
"mixmaker/backend/internal/domain"
|
||||||
|
"mixmaker/backend/internal/realtime"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
|
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
|
||||||
if !((createEventRequest{}).options().PingEveryone) {
|
if !((createEventRequest{}).options().PingEveryone) {
|
||||||
@@ -14,3 +26,67 @@ func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
|
|||||||
t.Fatal("explicit false pingEveryone must disable the ping")
|
t.Fatal("explicit false pingEveryone must disable the ping")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type profileHTTPStore struct {
|
||||||
|
application.Store
|
||||||
|
account domain.Account
|
||||||
|
player domain.Player
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *profileHTTPStore) AccountBySession(context.Context, string) (domain.Account, domain.Player, error) {
|
||||||
|
return s.account, s.player, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *profileHTTPStore) GetPlayer(context.Context, string) (domain.Player, error) {
|
||||||
|
return s.player, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *profileHTTPStore) ListEvents(context.Context, time.Time) ([]domain.Event, error) {
|
||||||
|
return []domain.Event{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayerProfileHTTPResponseIsPublicSafe(t *testing.T) {
|
||||||
|
store := &profileHTTPStore{
|
||||||
|
account: domain.Account{ID: "account", Role: domain.RolePlayer},
|
||||||
|
player: domain.Player{
|
||||||
|
ID: "player", AccountID: "account", DisplayName: "Ana", BattleTag: "Ana#1234",
|
||||||
|
Ratings: domain.Ratings{Tank: 10, Damage: 20, Support: 30},
|
||||||
|
PreferredPlayerIDs: []string{"secret-preference"}, AvoidedPlayerIDs: []string{"secret-avoid"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hub := realtime.New()
|
||||||
|
handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/players/player", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encoded := response.Body.String()
|
||||||
|
if strings.Contains(encoded, "preferred") || strings.Contains(encoded, "avoid") || strings.Contains(encoded, "accountId") {
|
||||||
|
t.Fatalf("profile leaked private fields: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeratorCannotUpdateCommunitySettings(t *testing.T) {
|
||||||
|
store := &profileHTTPStore{
|
||||||
|
account: domain.Account{ID: "moderator", Role: domain.RoleModerator},
|
||||||
|
player: domain.Player{ID: "player", AccountID: "moderator"},
|
||||||
|
}
|
||||||
|
hub := realtime.New()
|
||||||
|
handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "/api/community/settings", strings.NewReader(`{"discordInviteUrl":""}`))
|
||||||
|
request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("moderator update returned %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"mixmaker/backend/internal/application"
|
"mixmaker/backend/internal/application"
|
||||||
@@ -62,13 +63,27 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Account{}, domain.Player{}, err
|
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}
|
p, err := playerByAccount(ctx, tx, account.ID)
|
||||||
|
if err == nil {
|
||||||
|
return account, p, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.Account{}, domain.Player{}, err
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('mixmaker-player-display-names'))`); err != nil {
|
||||||
|
return domain.Account{}, domain.Player{}, err
|
||||||
|
}
|
||||||
|
displayName, err := uniqueDefaultDisplayName(ctx, tx, account.Username)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Account{}, domain.Player{}, err
|
||||||
|
}
|
||||||
|
p = domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: displayName, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
|
||||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,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
|
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,avoided_player_ids,created_at,updated_at`,
|
RETURNING id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_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).
|
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, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Account{}, domain.Player{}, err
|
return domain.Account{}, domain.Player{}, err
|
||||||
}
|
}
|
||||||
@@ -84,6 +99,51 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
|
|||||||
return account, p, tx.Commit(ctx)
|
return account, p, tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func playerByAccount(ctx context.Context, tx pgx.Tx, accountID string) (domain.Player, error) {
|
||||||
|
var p domain.Player
|
||||||
|
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||||
|
err := tx.QueryRow(ctx, `SELECT id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,
|
||||||
|
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE account_id=$1`, accountID).
|
||||||
|
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||||
|
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err == nil {
|
||||||
|
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
|
||||||
|
}
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueDefaultDisplayName(ctx context.Context, tx pgx.Tx, username string) (string, error) {
|
||||||
|
baseRunes := []rune(strings.TrimSpace(username))
|
||||||
|
if len(baseRunes) < 2 {
|
||||||
|
baseRunes = []rune("Player")
|
||||||
|
}
|
||||||
|
if len(baseRunes) > 32 {
|
||||||
|
baseRunes = baseRunes[:32]
|
||||||
|
}
|
||||||
|
base := string(baseRunes)
|
||||||
|
for number := 1; ; number++ {
|
||||||
|
candidate := base
|
||||||
|
if number > 1 {
|
||||||
|
suffix := fmt.Sprintf("-%d", number)
|
||||||
|
limit := 32 - len([]rune(suffix))
|
||||||
|
candidate = string([]rune(base)[:min(len([]rune(base)), limit)]) + suffix
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM players WHERE lower(display_name)=lower($1))`, candidate).Scan(&exists); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) {
|
func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) {
|
||||||
rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`)
|
rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,11 +184,11 @@ func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Acco
|
|||||||
var p domain.Player
|
var p domain.Player
|
||||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||||
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
|
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.avoided_player_ids,p.created_at,p.updated_at
|
p.id,p.account_id,p.display_name,p.battle_tag,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_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
|
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)).
|
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,
|
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, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||||
}
|
}
|
||||||
@@ -145,16 +205,35 @@ func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Playe
|
|||||||
preferredRoles, _ := json.Marshal(p.PreferredRoles)
|
preferredRoles, _ := json.Marshal(p.PreferredRoles)
|
||||||
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
|
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
|
||||||
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs)
|
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs)
|
||||||
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
|
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,battle_tag=$3,tank_rating=$4,damage_rating=$5,support_rating=$6,
|
||||||
preferred_roles=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9
|
preferred_roles=$7,preferred_player_ids=$8,avoided_player_ids=$9,updated_at=$10
|
||||||
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
|
WHERE id=$1 RETURNING id,COALESCE(account_id,''),display_name,battle_tag,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, avoidedPlayers, p.UpdatedAt).
|
p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
|
||||||
Scan(&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.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
return p, mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetPlayer(ctx context.Context, id string) (domain.Player, error) {
|
||||||
|
var p domain.Player
|
||||||
|
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
|
||||||
|
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE id=$1`, id).
|
||||||
|
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||||
|
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err == nil {
|
||||||
|
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
|
||||||
|
}
|
||||||
return p, mapError(err)
|
return p, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||||
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,
|
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
|
||||||
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -164,7 +243,7 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p domain.Player
|
var p domain.Player
|
||||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||||
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -189,11 +268,11 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||||
VALUES($1,NULL,$2,$3,$4,$5,$6,$7)
|
VALUES($1,NULL,$2,$3,$4,$5,$6,$7,$8)
|
||||||
RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
p.ID, p.DisplayName, p.BattleTag, 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, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||||
}
|
}
|
||||||
@@ -638,6 +717,27 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
|
|||||||
return out, mapError(err)
|
return out, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSeriesByEvent(ctx context.Context, eventID string) ([]domain.Series, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE body->>'eventId'=$1 ORDER BY id`, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]domain.Series, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var body []byte
|
||||||
|
var series domain.Series
|
||||||
|
if err := rows.Scan(&body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &series); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, series)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||||
body, _ := json.Marshal(t)
|
body, _ := json.Marshal(t)
|
||||||
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
|
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
|
||||||
@@ -749,6 +849,20 @@ func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, in
|
|||||||
return kind, version, mapError(err)
|
return kind, version, mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
|
||||||
|
var settings domain.CommunitySettings
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT discord_invite_url,updated_at,COALESCE(updated_by,'') FROM community_settings WHERE singleton=true`).
|
||||||
|
Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
|
||||||
|
return settings, mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateCommunitySettings(ctx context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
|
||||||
|
err := s.pool.QueryRow(ctx, `UPDATE community_settings SET discord_invite_url=$1,updated_at=$2,updated_by=$3 WHERE singleton=true
|
||||||
|
RETURNING discord_invite_url,updated_at,updated_by`, settings.DiscordInviteURL, settings.UpdatedAt, settings.UpdatedBy).
|
||||||
|
Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
|
||||||
|
return settings, mapError(err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
|
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
|
||||||
body, _ := json.Marshal(payload)
|
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)
|
_, 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)
|
||||||
@@ -806,5 +920,9 @@ func mapError(err error) error {
|
|||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return domain.ErrNotFound
|
return domain.ErrNotFound
|
||||||
}
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return fmt.Errorf("%w: value already exists", domain.ErrConflict)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
|||||||
t.Fatalf("%s table is missing", table)
|
t.Fatalf("%s table is missing", table)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var identityColumns int
|
||||||
|
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
|
||||||
|
WHERE table_name='players' AND column_name IN ('display_name','battle_tag')`).Scan(&identityColumns); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if identityColumns != 2 {
|
||||||
|
t.Fatalf("player identity migration is incomplete: found %d columns", identityColumns)
|
||||||
|
}
|
||||||
|
var settingsTable, nicknameIndex, seriesEventIndex bool
|
||||||
|
if err := store.pool.QueryRow(ctx, `SELECT
|
||||||
|
to_regclass('public.community_settings') IS NOT NULL,
|
||||||
|
to_regclass('public.players_display_name_lower_uidx') IS NOT NULL,
|
||||||
|
to_regclass('public.series_event_id_idx') IS NOT NULL`).Scan(&settingsTable, &nicknameIndex, &seriesEventIndex); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !settingsTable || !nicknameIndex || !seriesEventIndex {
|
||||||
|
t.Fatalf("profile/settings migrations missing: settings=%v nickname=%v series=%v", settingsTable, nicknameIndex, seriesEventIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscordRoleSyncPersistence(t *testing.T) {
|
func TestDiscordRoleSyncPersistence(t *testing.T) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Store interface {
|
|||||||
CreateSession(context.Context, string, string, time.Time) error
|
CreateSession(context.Context, string, string, time.Time) error
|
||||||
DeleteSession(context.Context, string) error
|
DeleteSession(context.Context, string) error
|
||||||
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
|
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
|
||||||
|
GetPlayer(context.Context, string) (domain.Player, error)
|
||||||
ListPlayers(context.Context) ([]domain.Player, error)
|
ListPlayers(context.Context) ([]domain.Player, error)
|
||||||
EnqueueDiscordRoleSync(context.Context, string, string, int64) error
|
EnqueueDiscordRoleSync(context.Context, string, string, int64) error
|
||||||
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
|
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
|
||||||
@@ -49,11 +50,14 @@ type Store interface {
|
|||||||
GetRuleset(context.Context, string) (domain.Ruleset, error)
|
GetRuleset(context.Context, string) (domain.Ruleset, error)
|
||||||
SaveSeries(context.Context, domain.Series) (domain.Series, error)
|
SaveSeries(context.Context, domain.Series) (domain.Series, error)
|
||||||
GetSeries(context.Context, string) (domain.Series, error)
|
GetSeries(context.Context, string) (domain.Series, error)
|
||||||
|
ListSeriesByEvent(context.Context, string) ([]domain.Series, error)
|
||||||
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
|
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
|
||||||
GetTournament(context.Context, string) (domain.Tournament, error)
|
GetTournament(context.Context, string) (domain.Tournament, error)
|
||||||
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
|
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
|
||||||
SaveDraft(context.Context, string, string, any, int) error
|
SaveDraft(context.Context, string, string, any, int) error
|
||||||
GetDraft(context.Context, string, any) (string, int, error)
|
GetDraft(context.Context, string, any) (string, int, error)
|
||||||
|
GetCommunitySettings(context.Context) (domain.CommunitySettings, error)
|
||||||
|
UpdateCommunitySettings(context.Context, domain.CommunitySettings) (domain.CommunitySettings, error)
|
||||||
AppendAudit(context.Context, string, string, string, any) error
|
AppendAudit(context.Context, string, string, string, any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,20 +129,35 @@ func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accoun
|
|||||||
return account, err
|
return account, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs, avoidedPlayerIDs []string) (domain.Player, error) {
|
type UpdateOwnProfileInput struct {
|
||||||
|
DisplayName *string
|
||||||
|
BattleTag *string
|
||||||
|
Ratings domain.Ratings
|
||||||
|
PreferredRoles []domain.Role
|
||||||
|
PreferredPlayerIDs []string
|
||||||
|
AvoidedPlayerIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, input UpdateOwnProfileInput) (domain.Player, error) {
|
||||||
if actor.ID != current.AccountID {
|
if actor.ID != current.AccountID {
|
||||||
return domain.Player{}, domain.ErrForbidden
|
return domain.Player{}, domain.ErrForbidden
|
||||||
}
|
}
|
||||||
if err := ratings.Validate(); err != nil {
|
if err := input.Ratings.Validate(); err != nil {
|
||||||
return domain.Player{}, err
|
return domain.Player{}, err
|
||||||
}
|
}
|
||||||
if displayName != "" {
|
if input.DisplayName != nil {
|
||||||
current.DisplayName = displayName
|
current.DisplayName = *input.DisplayName
|
||||||
}
|
}
|
||||||
current.Ratings = ratings
|
if input.BattleTag != nil {
|
||||||
current.PreferredRoles = preferredRoles
|
current.BattleTag = *input.BattleTag
|
||||||
current.PreferredPlayerIDs = preferredPlayerIDs
|
}
|
||||||
current.AvoidedPlayerIDs = avoidedPlayerIDs
|
if err := current.ValidateIdentity(); err != nil {
|
||||||
|
return domain.Player{}, err
|
||||||
|
}
|
||||||
|
current.Ratings = input.Ratings
|
||||||
|
current.PreferredRoles = input.PreferredRoles
|
||||||
|
current.PreferredPlayerIDs = input.PreferredPlayerIDs
|
||||||
|
current.AvoidedPlayerIDs = input.AvoidedPlayerIDs
|
||||||
current.UpdatedAt = s.Now()
|
current.UpdatedAt = s.Now()
|
||||||
if err := current.ValidatePreferences(); err != nil {
|
if err := current.ValidatePreferences(); err != nil {
|
||||||
return domain.Player{}, err
|
return domain.Player{}, err
|
||||||
@@ -168,6 +187,75 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPlayerProfile(ctx context.Context, playerID string) (domain.PlayerProfile, error) {
|
||||||
|
player, err := s.Store.GetPlayer(ctx, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PlayerProfile{}, err
|
||||||
|
}
|
||||||
|
events, err := s.Store.ListEvents(ctx, time.Unix(0, 0).UTC())
|
||||||
|
if err != nil {
|
||||||
|
return domain.PlayerProfile{}, err
|
||||||
|
}
|
||||||
|
matches := make([]domain.PlayerSeriesContext, 0)
|
||||||
|
for _, event := range events {
|
||||||
|
if event.State != domain.Completed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
teams, listErr := s.Store.ListTeams(ctx, event.ID)
|
||||||
|
if listErr != nil {
|
||||||
|
return domain.PlayerProfile{}, listErr
|
||||||
|
}
|
||||||
|
var ownTeam *domain.Team
|
||||||
|
for index := range teams {
|
||||||
|
for _, slot := range teams[index].Slots {
|
||||||
|
if slot.PlayerID == playerID {
|
||||||
|
ownTeam = &teams[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownTeam == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
series, listErr := s.Store.ListSeriesByEvent(ctx, event.ID)
|
||||||
|
if listErr != nil {
|
||||||
|
return domain.PlayerProfile{}, listErr
|
||||||
|
}
|
||||||
|
byID := make(map[string]domain.Team, len(teams))
|
||||||
|
for _, team := range teams {
|
||||||
|
byID[team.ID] = team
|
||||||
|
}
|
||||||
|
for _, item := range series {
|
||||||
|
teamA, okA := byID[item.TeamAID]
|
||||||
|
teamB, okB := byID[item.TeamBID]
|
||||||
|
if okA && okB {
|
||||||
|
matches = append(matches, domain.PlayerSeriesContext{Event: event, Series: item, TeamA: teamA, TeamB: teamB})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.BuildPlayerProfile(player, matches, 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
|
||||||
|
return s.Store.GetCommunitySettings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateCommunitySettings(ctx context.Context, actor domain.Account, inviteURL string) (domain.CommunitySettings, error) {
|
||||||
|
if !actor.IsAdmin() {
|
||||||
|
return domain.CommunitySettings{}, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
settings := domain.CommunitySettings{DiscordInviteURL: inviteURL, UpdatedAt: s.Now(), UpdatedBy: actor.ID}
|
||||||
|
if err := settings.Validate(); err != nil {
|
||||||
|
return domain.CommunitySettings{}, fmt.Errorf("%w: invalid Discord invite URL", err)
|
||||||
|
}
|
||||||
|
out, err := s.Store.UpdateCommunitySettings(ctx, settings)
|
||||||
|
if err == nil {
|
||||||
|
_ = s.Store.AppendAudit(ctx, actor.ID, "community.settings_updated", "community", out)
|
||||||
|
s.Bus.Publish("community-settings", out)
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
|
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
|
||||||
if !actor.IsStaff() {
|
if !actor.IsStaff() {
|
||||||
return domain.Event{}, domain.ErrForbidden
|
return domain.Event{}, domain.ErrForbidden
|
||||||
@@ -300,6 +388,9 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
if err := player.ValidateIdentity(); err != nil {
|
||||||
|
return domain.Player{}, domain.RSVP{}, err
|
||||||
|
}
|
||||||
rsvp := domain.RSVP{
|
rsvp := domain.RSVP{
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
PlayerID: player.ID,
|
PlayerID: player.ID,
|
||||||
|
|||||||
@@ -121,6 +121,85 @@ func TestCreateEventCanAnnounceWithoutEveryonePing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type communitySettingsStore struct {
|
||||||
|
Store
|
||||||
|
saved domain.CommunitySettings
|
||||||
|
audits int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *communitySettingsStore) UpdateCommunitySettings(_ context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
|
||||||
|
s.saved = settings
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *communitySettingsStore) AppendAudit(_ context.Context, _, _, _ string, _ any) error {
|
||||||
|
s.audits++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCommunitySettingsRequiresAdminAndAudits(t *testing.T) {
|
||||||
|
store := &communitySettingsStore{}
|
||||||
|
service := New(store, &recordingPublisher{}, nil)
|
||||||
|
now := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
|
||||||
|
service.Now = func() time.Time { return now }
|
||||||
|
|
||||||
|
if _, err := service.UpdateCommunitySettings(context.Background(), domain.Account{Role: domain.RoleModerator}, ""); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Fatalf("moderator must be forbidden, got %v", err)
|
||||||
|
}
|
||||||
|
out, err := service.UpdateCommunitySettings(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, " https://discord.gg/mixmaker ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.DiscordInviteURL != "https://discord.gg/mixmaker" || out.UpdatedBy != "admin" || !out.UpdatedAt.Equal(now) {
|
||||||
|
t.Fatalf("unexpected settings: %+v", out)
|
||||||
|
}
|
||||||
|
if store.audits != 1 {
|
||||||
|
t.Fatalf("expected one audit entry, got %d", store.audits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type playerProfileStore struct {
|
||||||
|
Store
|
||||||
|
players []domain.Player
|
||||||
|
saved domain.Player
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playerProfileStore) ListPlayers(context.Context) ([]domain.Player, error) {
|
||||||
|
return s.players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *playerProfileStore) UpdatePlayer(_ context.Context, player domain.Player) (domain.Player, error) {
|
||||||
|
s.saved = player
|
||||||
|
return player, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateOwnProfileTrimsIdentityAndClearsBattleTag(t *testing.T) {
|
||||||
|
store := &playerProfileStore{players: []domain.Player{{ID: "player"}}}
|
||||||
|
service := New(store, &recordingPublisher{}, nil)
|
||||||
|
name, battleTag := " New Name ", ""
|
||||||
|
out, err := service.UpdateOwnProfile(context.Background(),
|
||||||
|
domain.Account{ID: "account"},
|
||||||
|
domain.Player{ID: "player", AccountID: "account", DisplayName: "Old", BattleTag: "Old#1234"},
|
||||||
|
UpdateOwnProfileInput{
|
||||||
|
DisplayName: &name, BattleTag: &battleTag,
|
||||||
|
Ratings: domain.Ratings{Tank: 1, Damage: 20, Support: 40},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.DisplayName != "New Name" || out.BattleTag != "" {
|
||||||
|
t.Fatalf("identity was not normalized and cleared: %+v", out)
|
||||||
|
}
|
||||||
|
if _, err = service.UpdateOwnProfile(context.Background(),
|
||||||
|
domain.Account{ID: "other"}, domain.Player{AccountID: "account"},
|
||||||
|
UpdateOwnProfileInput{},
|
||||||
|
); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Fatalf("profile updates must remain self-only, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validEvent(now time.Time) domain.Event {
|
func validEvent(now time.Time) domain.Event {
|
||||||
return domain.Event{
|
return domain.Event{
|
||||||
Name: "Sunday Mix",
|
Name: "Sunday Mix",
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -63,6 +65,7 @@ type Player struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
AccountID string `json:"accountId"`
|
AccountID string `json:"accountId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
BattleTag string `json:"battleTag"`
|
||||||
Ratings Ratings `json:"ratings"`
|
Ratings Ratings `json:"ratings"`
|
||||||
PreferredRoles []Role `json:"preferredRoles"`
|
PreferredRoles []Role `json:"preferredRoles"`
|
||||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||||
@@ -71,6 +74,21 @@ type Player struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var battleTagPattern = regexp.MustCompile(`^[^#\s]{2,20}#[0-9]{3,12}$`)
|
||||||
|
|
||||||
|
func (p *Player) ValidateIdentity() error {
|
||||||
|
p.DisplayName = strings.TrimSpace(p.DisplayName)
|
||||||
|
length := utf8.RuneCountInString(p.DisplayName)
|
||||||
|
if length < 2 || length > 32 {
|
||||||
|
return fmt.Errorf("%w: display name must contain 2 to 32 characters", ErrInvalid)
|
||||||
|
}
|
||||||
|
p.BattleTag = strings.TrimSpace(p.BattleTag)
|
||||||
|
if p.BattleTag != "" && !battleTagPattern.MatchString(p.BattleTag) {
|
||||||
|
return fmt.Errorf("%w: BattleTag must have the form Name#digits", ErrInvalid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p Player) ValidatePreferences() error {
|
func (p Player) ValidatePreferences() error {
|
||||||
if len(p.PreferredPlayerIDs) > 3 {
|
if len(p.PreferredPlayerIDs) > 3 {
|
||||||
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
||||||
|
|||||||
169
backend/internal/domain/player_profile.go
Normal file
169
backend/internal/domain/player_profile.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PublicPlayer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
BattleTag string `json:"battleTag"`
|
||||||
|
Ratings Ratings `json:"ratings"`
|
||||||
|
IsGuest bool `json:"isGuest"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerStatsSummary struct {
|
||||||
|
SeriesPlayed int `json:"seriesPlayed"`
|
||||||
|
SeriesWon int `json:"seriesWon"`
|
||||||
|
SeriesLost int `json:"seriesLost"`
|
||||||
|
WinRate float64 `json:"winRate"`
|
||||||
|
MapsPlayed int `json:"mapsPlayed"`
|
||||||
|
MapsWon int `json:"mapsWon"`
|
||||||
|
MapsLost int `json:"mapsLost"`
|
||||||
|
MapsDrawn int `json:"mapsDrawn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerMatchHistory struct {
|
||||||
|
SeriesID string `json:"seriesId"`
|
||||||
|
EventID string `json:"eventId"`
|
||||||
|
EventName string `json:"eventName"`
|
||||||
|
EventDate time.Time `json:"eventDate"`
|
||||||
|
OwnTeamID string `json:"ownTeamId"`
|
||||||
|
OwnTeamName string `json:"ownTeamName"`
|
||||||
|
OpponentTeamID string `json:"opponentTeamId"`
|
||||||
|
OpponentTeamName string `json:"opponentTeamName"`
|
||||||
|
OwnScore int `json:"ownScore"`
|
||||||
|
OpponentScore int `json:"opponentScore"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerProfile struct {
|
||||||
|
Player PublicPlayer `json:"player"`
|
||||||
|
Summary PlayerStatsSummary `json:"summary"`
|
||||||
|
Recent []PlayerMatchHistory `json:"recentMatches"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerSeriesContext struct {
|
||||||
|
Event Event
|
||||||
|
Series Series
|
||||||
|
TeamA Team
|
||||||
|
TeamB Team
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildPlayerProfile(player Player, matches []PlayerSeriesContext, recentLimit int) PlayerProfile {
|
||||||
|
profile := PlayerProfile{
|
||||||
|
Player: PublicPlayer{
|
||||||
|
ID: player.ID, DisplayName: player.DisplayName, BattleTag: player.BattleTag,
|
||||||
|
Ratings: player.Ratings, IsGuest: player.AccountID == "",
|
||||||
|
},
|
||||||
|
Recent: []PlayerMatchHistory{},
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.Event.State != Completed || match.Series.Phase != SeriesComplete || match.Series.WinnerTeamID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
own, opponent, ownIsA := teamContaining(match.TeamA, player.ID), match.TeamB, true
|
||||||
|
if own.ID == "" {
|
||||||
|
own, opponent, ownIsA = teamContaining(match.TeamB, player.ID), match.TeamA, false
|
||||||
|
}
|
||||||
|
if own.ID == "" || opponent.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ownScore, opponentScore, mapsWon, mapsLost, mapsDrawn := scoreEffectiveResults(match.Series, ownIsA)
|
||||||
|
profile.Summary.SeriesPlayed++
|
||||||
|
profile.Summary.MapsPlayed += mapsWon + mapsLost + mapsDrawn
|
||||||
|
profile.Summary.MapsWon += mapsWon
|
||||||
|
profile.Summary.MapsLost += mapsLost
|
||||||
|
profile.Summary.MapsDrawn += mapsDrawn
|
||||||
|
outcome := "loss"
|
||||||
|
if match.Series.WinnerTeamID == own.ID {
|
||||||
|
outcome = "win"
|
||||||
|
profile.Summary.SeriesWon++
|
||||||
|
} else {
|
||||||
|
profile.Summary.SeriesLost++
|
||||||
|
}
|
||||||
|
profile.Recent = append(profile.Recent, PlayerMatchHistory{
|
||||||
|
SeriesID: match.Series.ID, EventID: match.Event.ID, EventName: match.Event.Name, EventDate: match.Event.StartsAt,
|
||||||
|
OwnTeamID: own.ID, OwnTeamName: own.Name, OpponentTeamID: opponent.ID, OpponentTeamName: opponent.Name,
|
||||||
|
OwnScore: ownScore, OpponentScore: opponentScore, Outcome: outcome,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if profile.Summary.SeriesPlayed > 0 {
|
||||||
|
profile.Summary.WinRate = float64(profile.Summary.SeriesWon) / float64(profile.Summary.SeriesPlayed)
|
||||||
|
}
|
||||||
|
sort.SliceStable(profile.Recent, func(i, j int) bool {
|
||||||
|
if profile.Recent[i].EventDate.Equal(profile.Recent[j].EventDate) {
|
||||||
|
return profile.Recent[i].SeriesID > profile.Recent[j].SeriesID
|
||||||
|
}
|
||||||
|
return profile.Recent[i].EventDate.After(profile.Recent[j].EventDate)
|
||||||
|
})
|
||||||
|
if recentLimit >= 0 && len(profile.Recent) > recentLimit {
|
||||||
|
profile.Recent = profile.Recent[:recentLimit]
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func teamContaining(team Team, playerID string) Team {
|
||||||
|
for _, slot := range team.Slots {
|
||||||
|
if slot.PlayerID == playerID {
|
||||||
|
return team
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Team{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreEffectiveResults(series Series, ownIsA bool) (ownScore, opponentScore, won, lost, drawn int) {
|
||||||
|
superseded := make(map[int]bool)
|
||||||
|
for _, result := range series.Results {
|
||||||
|
if result.CorrectionOf > 0 {
|
||||||
|
superseded[result.CorrectionOf] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, result := range series.Results {
|
||||||
|
if superseded[index+1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch result.Outcome {
|
||||||
|
case Draw:
|
||||||
|
drawn++
|
||||||
|
case TeamAWin:
|
||||||
|
if ownIsA {
|
||||||
|
ownScore, won = ownScore+1, won+1
|
||||||
|
} else {
|
||||||
|
opponentScore, lost = opponentScore+1, lost+1
|
||||||
|
}
|
||||||
|
case TeamBWin:
|
||||||
|
if ownIsA {
|
||||||
|
opponentScore, lost = opponentScore+1, lost+1
|
||||||
|
} else {
|
||||||
|
ownScore, won = ownScore+1, won+1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ownScore, opponentScore, won, lost, drawn
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommunitySettings struct {
|
||||||
|
DiscordInviteURL string `json:"discordInviteUrl"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
UpdatedBy string `json:"updatedBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (settings *CommunitySettings) Validate() error {
|
||||||
|
settings.DiscordInviteURL = strings.TrimSpace(settings.DiscordInviteURL)
|
||||||
|
if settings.DiscordInviteURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(settings.DiscordInviteURL, "https://discord.gg/") &&
|
||||||
|
!strings.HasPrefix(settings.DiscordInviteURL, "https://discord.com/invite/") {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
code := strings.TrimPrefix(settings.DiscordInviteURL, "https://discord.gg/")
|
||||||
|
code = strings.TrimPrefix(code, "https://discord.com/invite/")
|
||||||
|
if code == "" || strings.ContainsAny(code, "/?# \t\r\n") {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
80
backend/internal/domain/player_profile_test.go
Normal file
80
backend/internal/domain/player_profile_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlayerIdentityValidation(t *testing.T) {
|
||||||
|
player := Player{DisplayName: " Ana Main ", BattleTag: "Ana#1234"}
|
||||||
|
if err := player.ValidateIdentity(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if player.DisplayName != "Ana Main" {
|
||||||
|
t.Fatalf("display name was not trimmed: %q", player.DisplayName)
|
||||||
|
}
|
||||||
|
player.DisplayName = "A"
|
||||||
|
if !errors.Is(player.ValidateIdentity(), ErrInvalid) {
|
||||||
|
t.Fatal("one-character display name must be invalid")
|
||||||
|
}
|
||||||
|
player.DisplayName, player.BattleTag = "Valid", "invalid"
|
||||||
|
if !errors.Is(player.ValidateIdentity(), ErrInvalid) {
|
||||||
|
t.Fatal("invalid BattleTag must be rejected")
|
||||||
|
}
|
||||||
|
player.BattleTag = ""
|
||||||
|
if err := player.ValidateIdentity(); err != nil {
|
||||||
|
t.Fatalf("empty BattleTag must clear the value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPlayerProfileUsesEffectiveResultsAndFinalRoster(t *testing.T) {
|
||||||
|
start := time.Date(2026, time.July, 19, 18, 0, 0, 0, time.UTC)
|
||||||
|
event := Event{ID: "event", Name: "Sunday Mix", StartsAt: start, State: Completed}
|
||||||
|
teamA := Team{ID: "a", Name: "Alpha", Slots: []Slot{{PlayerID: "player"}}}
|
||||||
|
teamB := Team{ID: "b", Name: "Bravo", Slots: []Slot{{PlayerID: "opponent"}}}
|
||||||
|
series := Series{
|
||||||
|
ID: "series", EventID: event.ID, TeamAID: teamA.ID, TeamBID: teamB.ID,
|
||||||
|
WinnerTeamID: teamA.ID, Phase: SeriesComplete,
|
||||||
|
Results: []MapResult{
|
||||||
|
{Outcome: TeamBWin},
|
||||||
|
{Outcome: TeamAWin},
|
||||||
|
{Outcome: Draw},
|
||||||
|
{Outcome: TeamAWin, CorrectionOf: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
profile := BuildPlayerProfile(Player{ID: "player", DisplayName: "Player", AccountID: ""}, []PlayerSeriesContext{
|
||||||
|
{Event: event, Series: series, TeamA: teamA, TeamB: teamB},
|
||||||
|
{Event: Event{State: Live}, Series: series, TeamA: teamA, TeamB: teamB},
|
||||||
|
{Event: event, Series: series, TeamA: Team{ID: "a"}, TeamB: teamB},
|
||||||
|
}, 10)
|
||||||
|
|
||||||
|
if profile.Summary.SeriesPlayed != 1 || profile.Summary.SeriesWon != 1 || profile.Summary.WinRate != 1 {
|
||||||
|
t.Fatalf("unexpected series summary: %+v", profile.Summary)
|
||||||
|
}
|
||||||
|
if profile.Summary.MapsPlayed != 3 || profile.Summary.MapsWon != 2 ||
|
||||||
|
profile.Summary.MapsLost != 0 || profile.Summary.MapsDrawn != 1 {
|
||||||
|
t.Fatalf("corrected map result was not aggregated correctly: %+v", profile.Summary)
|
||||||
|
}
|
||||||
|
if len(profile.Recent) != 1 || profile.Recent[0].OwnScore != 2 || profile.Recent[0].OpponentScore != 0 {
|
||||||
|
t.Fatalf("unexpected history: %+v", profile.Recent)
|
||||||
|
}
|
||||||
|
if !profile.Player.IsGuest {
|
||||||
|
t.Fatal("account-less players must be represented as guests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunitySettingsValidation(t *testing.T) {
|
||||||
|
for _, value := range []string{"", "https://discord.gg/mixmaker", "https://discord.com/invite/mixmaker"} {
|
||||||
|
settings := CommunitySettings{DiscordInviteURL: value}
|
||||||
|
if err := settings.Validate(); err != nil {
|
||||||
|
t.Fatalf("%q should be valid: %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []string{"http://discord.gg/code", "https://example.com/invite", "https://discord.gg/", "https://discord.gg/code/path"} {
|
||||||
|
settings := CommunitySettings{DiscordInviteURL: value}
|
||||||
|
if !errors.Is(settings.Validate(), ErrInvalid) {
|
||||||
|
t.Fatalf("%q should be invalid", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/migrations/015_player_identity.sql
Normal file
53
backend/migrations/015_player_identity.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- +mixmaker Up
|
||||||
|
ALTER TABLE players
|
||||||
|
ADD COLUMN IF NOT EXISTS battle_tag text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
item record;
|
||||||
|
base_name text;
|
||||||
|
candidate text;
|
||||||
|
suffix text;
|
||||||
|
duplicate_number integer;
|
||||||
|
BEGIN
|
||||||
|
FOR item IN SELECT id, display_name FROM players ORDER BY created_at, id LOOP
|
||||||
|
base_name := regexp_replace(item.display_name, '^[[:space:]]+|[[:space:]]+$', '', 'g');
|
||||||
|
IF char_length(base_name) < 2 THEN
|
||||||
|
base_name := 'Player';
|
||||||
|
END IF;
|
||||||
|
base_name := left(base_name, 32);
|
||||||
|
candidate := base_name;
|
||||||
|
duplicate_number := 1;
|
||||||
|
WHILE EXISTS (
|
||||||
|
SELECT 1 FROM players
|
||||||
|
WHERE id <> item.id AND lower(display_name) = lower(candidate)
|
||||||
|
) LOOP
|
||||||
|
duplicate_number := duplicate_number + 1;
|
||||||
|
suffix := '-' || duplicate_number::text;
|
||||||
|
candidate := left(base_name, 32 - char_length(suffix)) || suffix;
|
||||||
|
END LOOP;
|
||||||
|
UPDATE players SET display_name = candidate WHERE id = item.id;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE players
|
||||||
|
DROP CONSTRAINT IF EXISTS players_display_name_valid,
|
||||||
|
ADD CONSTRAINT players_display_name_valid CHECK (
|
||||||
|
display_name = regexp_replace(display_name, '^[[:space:]]+|[[:space:]]+$', '', 'g')
|
||||||
|
AND char_length(display_name) BETWEEN 2 AND 32
|
||||||
|
),
|
||||||
|
DROP CONSTRAINT IF EXISTS players_battle_tag_valid,
|
||||||
|
ADD CONSTRAINT players_battle_tag_valid CHECK (
|
||||||
|
battle_tag = ''
|
||||||
|
OR battle_tag ~ '^[^#[:space:]]{2,20}#[0-9]{3,12}$'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS players_display_name_lower_uidx
|
||||||
|
ON players (lower(display_name));
|
||||||
|
|
||||||
|
-- +mixmaker Down
|
||||||
|
DROP INDEX IF EXISTS players_display_name_lower_uidx;
|
||||||
|
ALTER TABLE players
|
||||||
|
DROP CONSTRAINT IF EXISTS players_battle_tag_valid,
|
||||||
|
DROP CONSTRAINT IF EXISTS players_display_name_valid,
|
||||||
|
DROP COLUMN IF EXISTS battle_tag;
|
||||||
20
backend/migrations/016_community_settings_and_stats.sql
Normal file
20
backend/migrations/016_community_settings_and_stats.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- +mixmaker Up
|
||||||
|
CREATE TABLE community_settings (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||||
|
discord_invite_url text NOT NULL DEFAULT '',
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_by text REFERENCES accounts(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO community_settings(singleton) VALUES (true)
|
||||||
|
ON CONFLICT(singleton) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS series_event_id_idx
|
||||||
|
ON series ((body->>'eventId'));
|
||||||
|
CREATE INDEX IF NOT EXISTS events_completed_starts_at_idx
|
||||||
|
ON events (starts_at DESC) WHERE state = 'Completed';
|
||||||
|
|
||||||
|
-- +mixmaker Down
|
||||||
|
DROP INDEX IF EXISTS events_completed_starts_at_idx;
|
||||||
|
DROP INDEX IF EXISTS series_event_id_idx;
|
||||||
|
DROP TABLE IF EXISTS community_settings;
|
||||||
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { demoSeries } from './api/demo'
|
import { demoSeries } from './api/demo'
|
||||||
|
import { LanguageProvider } from './i18n'
|
||||||
import { activeHeroBanMap } from './series-utils'
|
import { activeHeroBanMap } from './series-utils'
|
||||||
|
|
||||||
describe('Mixmaker frontend', () => {
|
describe('Mixmaker frontend', () => {
|
||||||
@@ -12,7 +13,7 @@ describe('Mixmaker frontend', () => {
|
|||||||
|
|
||||||
it('renders the event dashboard', async () => {
|
it('renders the event dashboard', async () => {
|
||||||
window.history.replaceState({}, '', '/events')
|
window.history.replaceState({}, '', '/events')
|
||||||
render(<App />)
|
render(<LanguageProvider><App /></LanguageProvider>)
|
||||||
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
|
||||||
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
|
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live')
|
expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live')
|
||||||
@@ -23,13 +24,13 @@ describe('Mixmaker frontend', () => {
|
|||||||
|
|
||||||
it('renders the Discord login screen', async () => {
|
it('renders the Discord login screen', async () => {
|
||||||
window.history.replaceState({}, '', '/login')
|
window.history.replaceState({}, '', '/login')
|
||||||
render(<App />)
|
render(<LanguageProvider><App /></LanguageProvider>)
|
||||||
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
|
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('locks RSVP and exposes live navigation after the scrim starts', async () => {
|
it('locks RSVP and exposes live navigation after the scrim starts', async () => {
|
||||||
window.history.replaceState({}, '', '/events/event-3')
|
window.history.replaceState({}, '', '/events/event-3')
|
||||||
render(<App />)
|
render(<LanguageProvider><App /></LanguageProvider>)
|
||||||
expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live')
|
expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live')
|
||||||
expect(screen.getByRole('heading', { name: 'Balanced teams' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: 'Balanced teams' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('heading', { name: 'Ember Wolves' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: 'Ember Wolves' })).toBeInTheDocument()
|
||||||
@@ -70,5 +71,42 @@ describe('Mixmaker frontend', () => {
|
|||||||
expect(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).toBeInTheDocument()
|
||||||
expect(screen.getByText(/team that lost that map immediately chooses/i)).toBeInTheDocument()
|
expect(screen.getByText(/team that lost that map immediately chooses/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /open discord/i })).toHaveAttribute('href', 'https://discord.gg/mixmaker')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens linked players and renders profile stats and history', async () => {
|
||||||
|
window.history.replaceState({}, '', '/players/p2')
|
||||||
|
render(<LanguageProvider><App /></LanguageProvider>)
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Nova', level: 1 })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Nova#1002')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('region', { name: 'Player statistics' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Recent matches' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /Summer Clash #4/i })).toHaveAttribute('href', '/watch/series-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links player names without wrapping RSVP controls', async () => {
|
||||||
|
window.history.replaceState({}, '', '/events/event-3')
|
||||||
|
render(<App />)
|
||||||
|
const nova = await screen.findAllByRole('link', { name: 'Nova' })
|
||||||
|
expect(nova.some((link) => link.getAttribute('href') === '/players/p2')).toBe(true)
|
||||||
|
expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes community settings to administrators', async () => {
|
||||||
|
window.history.replaceState({}, '', '/admin')
|
||||||
|
render(<LanguageProvider><App /></LanguageProvider>)
|
||||||
|
fireEvent.click(await screen.findByRole('tab', { name: 'Community' }))
|
||||||
|
expect(screen.getByRole('heading', { name: 'Community Discord' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Discord invite URL' })).toHaveValue('https://discord.gg/mixmaker')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits player identity', async () => {
|
||||||
|
window.history.replaceState({}, '', '/profile')
|
||||||
|
render(<LanguageProvider><App /></LanguageProvider>)
|
||||||
|
const nickname = await screen.findByRole('textbox', { name: 'Nickname' })
|
||||||
|
fireEvent.change(nickname, { target: { value: 'Lemintare Prime' } })
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: /BattleTag/i }), { target: { value: 'Lemi#2026' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save profile' }))
|
||||||
|
expect(await screen.findByText('Saved')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
|
Bell, CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, ExternalLink, Gamepad2, ListFilter, LogOut,
|
||||||
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
|
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
|
||||||
UsersRound, X, Zap,
|
UsersRound, X, Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
|
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
|
||||||
type Player, type PlayerRole, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
type Player, type PlayerRole, type PublicPlayerProfile, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
||||||
} from './api/client'
|
} from './api/client'
|
||||||
import {
|
import {
|
||||||
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
|
demoBalanceCandidates, demoBracket, demoCommunitySettings, demoEvents, demoPlayerProfiles, demoProfile,
|
||||||
demoMode, demoRegistrations, demoSeries, demoSession,
|
demoMode, demoRegistrations, demoSeries, demoSession,
|
||||||
} from './api/demo'
|
} from './api/demo'
|
||||||
import { LanguageSelector } from './i18n'
|
import { LanguageSelector } from './i18n'
|
||||||
@@ -35,6 +35,7 @@ const protectedRoute = createRoute({
|
|||||||
})
|
})
|
||||||
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
|
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
|
||||||
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
|
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
|
||||||
|
const playerRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/players/$playerId', component: PublicPlayerProfilePage })
|
||||||
const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
|
const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
|
||||||
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
|
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
|
||||||
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
|
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
|
||||||
@@ -52,7 +53,7 @@ const adminRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
|
beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
|
||||||
})
|
})
|
||||||
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
|
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
|
||||||
indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
|
indexRoute, profileRoute, playerRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
|
||||||
])])
|
])])
|
||||||
const router = createRouter({ routeTree, context: { session: null } })
|
const router = createRouter({ routeTree, context: { session: null } })
|
||||||
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
|
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
|
||||||
@@ -118,7 +119,7 @@ function AppShell() {
|
|||||||
void router.navigate({ to: '/login' })
|
void router.navigate({ to: '/login' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
|
const initials = session.data?.player.displayName.slice(0, 2).toUpperCase() ?? 'MM'
|
||||||
const navItems = isStaff(session.data?.account.role)
|
const navItems = isStaff(session.data?.account.role)
|
||||||
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
|
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
|
||||||
: mainNavItems
|
: mainNavItems
|
||||||
@@ -148,6 +149,9 @@ function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string;
|
|||||||
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
|
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | '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 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, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldn’t load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
|
function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldn’t load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
|
||||||
|
function PlayerLink({ player, className }: { player: Pick<Player, 'id' | 'displayName'>; className?: string }) {
|
||||||
|
return <Link className={`player-link ${className ?? ''}`.trim()} to="/players/$playerId" params={{ playerId: player.id }}>{player.displayName}</Link>
|
||||||
|
}
|
||||||
|
|
||||||
function EventsPage() {
|
function EventsPage() {
|
||||||
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
|
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
|
||||||
@@ -221,7 +225,7 @@ function EventPage() {
|
|||||||
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
|
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</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>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by 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 || event.workflowState !== 'RegistrationOpen'} 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>
|
<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>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by 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 || event.workflowState !== 'RegistrationOpen'} 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>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</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>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
|
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</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>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
|
||||||
</div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
|
</div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><PlayerLink player={registration.player} /><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveEntryPage() {
|
function LiveEntryPage() {
|
||||||
@@ -260,7 +264,9 @@ function LiveEntryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FAQPage() {
|
function FAQPage() {
|
||||||
|
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
|
||||||
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
|
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
|
||||||
|
<section className="card faq-card community-faq"><span><Bell /></span><div><h2>Join the community Discord</h2><p>Discord is where event announcements, role updates, and match notifications arrive. Join the server and enable the bot roles you want to be notified about.</p>{settings.data?.discordInviteUrl && <a className="button secondary" href={settings.data.discordInviteUrl} target="_blank" rel="noreferrer">Open Discord <ExternalLink /></a>}</div></section>
|
||||||
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.</p></div></section>
|
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.</p></div></section>
|
||||||
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Can’t go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
|
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Can’t go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
|
||||||
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.</p></div></section>
|
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.</p></div></section>
|
||||||
@@ -272,6 +278,44 @@ function FAQPage() {
|
|||||||
</div></div>
|
</div></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PublicPlayerProfilePage() {
|
||||||
|
const { playerId } = playerRoute.useParams()
|
||||||
|
const language = useLanguage()
|
||||||
|
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||||||
|
const fallback = demoPlayerProfiles.find((player) => player.id === playerId)
|
||||||
|
const profile = useQuery({
|
||||||
|
queryKey: ['player-profile', playerId],
|
||||||
|
queryFn: () => api.playerProfile(playerId),
|
||||||
|
initialData: demoMode ? fallback : undefined,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
if (profile.isLoading || session.isLoading) return <LoadingState label="Loading player profile…" />
|
||||||
|
if (profile.isError || session.isError || !profile.data) return <ErrorState message="This player profile could not be found." retry={() => void profile.refetch()} />
|
||||||
|
const player: PublicPlayerProfile = profile.data
|
||||||
|
const stats = player.stats
|
||||||
|
const winRate = stats.winRate <= 1 ? Math.round(stats.winRate * 100) : Math.round(stats.winRate)
|
||||||
|
const roles: Array<{ key: PlayerRole; label: string; icon: ReactNode }> = [
|
||||||
|
{ key: 'tank', label: 'Tank', icon: <Shield /> },
|
||||||
|
{ key: 'damage', label: 'Damage', icon: <Zap /> },
|
||||||
|
{ key: 'support', label: 'Support', icon: <Sparkles /> },
|
||||||
|
]
|
||||||
|
return <div className="page player-profile-page">
|
||||||
|
<PageHeader eyebrow="Player profile" title={player.displayName} description="Competitive history and role ranks." actions={session.data?.player.id === player.id ? <Link className="button secondary" to="/profile">Edit profile</Link> : undefined} />
|
||||||
|
<section className="profile-identity card"><div className="large-avatar">{player.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{player.displayName}</h2>{player.battleTag && <p>{player.battleTag}</p>}<Badge tone={player.isGuest ? 'neutral' : 'blue'}>{player.isGuest ? 'Guest player' : 'Discord player'}</Badge></div></section>
|
||||||
|
<div className="public-profile-grid">
|
||||||
|
<section className="card profile-ranks"><div className="section-title"><div><h2>Role ranks</h2><p>Current competitive ratings</p></div></div>{roles.map((role) => <div className={`profile-rank role-${role.key}`} key={role.key}><span className="role-icon">{role.icon}</span><span><small>{role.label}</small><strong>{rankLabel(player.ratings[role.key], language)}</strong></span></div>)}</section>
|
||||||
|
<section className="profile-stat-grid" aria-label="Player statistics">
|
||||||
|
<div className="card"><span>Series played</span><strong>{stats.seriesPlayed}</strong></div>
|
||||||
|
<div className="card"><span>Series won</span><strong>{stats.seriesWon}</strong></div>
|
||||||
|
<div className="card"><span>Series lost</span><strong>{stats.seriesLost}</strong></div>
|
||||||
|
<div className="card highlight"><span>Win rate</span><strong>{winRate}%</strong></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section className="card map-record"><div className="section-title"><div><h2>Map record</h2><p>{stats.mapsPlayed} maps played</p></div></div><div><span><strong>{stats.mapsWon}</strong>Won</span><span><strong>{stats.mapsLost}</strong>Lost</span><span><strong>{stats.mapsDrawn}</strong>Drawn</span></div></section>
|
||||||
|
<section className="card recent-matches"><div className="section-title"><div><h2>Recent matches</h2><p>Latest completed series</p></div></div>{player.recentMatches.length === 0 ? <p className="empty-note">No completed matches yet.</p> : <div>{player.recentMatches.map((match) => <Link to="/watch/$seriesId" params={{ seriesId: match.seriesId }} className="recent-match" key={match.seriesId}><div><Badge tone={match.outcome === 'win' ? 'success' : 'danger'}>{match.outcome}</Badge><span><strong>{match.eventName}</strong><small>{new Date(match.startedAt).toLocaleDateString()}</small></span></div><div className="recent-match-score"><span>{match.teamName}</span><strong>{match.scoreFor}<i>:</i>{match.scoreAgainst}</strong><span>{match.opponentTeamName}</span></div><ChevronRight /></Link>)}</div>}</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -279,11 +323,15 @@ function ProfilePage() {
|
|||||||
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined })
|
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 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 [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [battleTag, setBattleTag] = useState('')
|
||||||
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
|
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
|
||||||
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
|
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
|
||||||
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
|
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile.data) {
|
if (profile.data) {
|
||||||
|
setDisplayName(profile.data.displayName)
|
||||||
|
setBattleTag(profile.data.battleTag ?? '')
|
||||||
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
|
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
|
||||||
setPreferredRoles(profile.data.preferredRoles)
|
setPreferredRoles(profile.data.preferredRoles)
|
||||||
setPreferredPlayerIds(profile.data.preferredPlayerIds)
|
setPreferredPlayerIds(profile.data.preferredPlayerIds)
|
||||||
@@ -298,7 +346,9 @@ function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}, [players.data])
|
}, [players.data])
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
|
mutationFn: () => demoMode
|
||||||
|
? Promise.resolve({ ...profile.data!, displayName: displayName.trim(), battleTag: battleTag.trim() || undefined, ratings: { ...ratings, updatedAt: new Date().toISOString() }, preferredRoles, preferredPlayerIds, avoidedPlayerIds })
|
||||||
|
: api.updateRatings(displayName.trim(), battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
|
||||||
onSuccess: (player) => {
|
onSuccess: (player) => {
|
||||||
client.setQueryData(['profile'], player)
|
client.setQueryData(['profile'], player)
|
||||||
void client.invalidateQueries({ queryKey: ['session'] })
|
void client.invalidateQueries({ queryKey: ['session'] })
|
||||||
@@ -310,10 +360,10 @@ function ProfilePage() {
|
|||||||
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 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() }
|
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
|
||||||
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
|
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
|
||||||
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">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.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><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((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>
|
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">{displayName.slice(0, 2).toUpperCase()}</div><div><h2>{displayName || profile.data.displayName}</h2>{battleTag && <p>{battleTag}</p>}<Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><section className="identity-fields"><label>Nickname<input required minLength={2} maxLength={32} autoComplete="nickname" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label><label>BattleTag <small>Optional</small><input maxLength={33} placeholder="Player#1234" value={battleTag} onChange={(event) => setBattleTag(event.target.value)} /></label><p>Nicknames are unique across the community.</p></section><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">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.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><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((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 || !displayName.trim()}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminTab = 'events' | 'participants' | 'balance' | 'moderators'
|
type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' | 'community'
|
||||||
function AdminPage() {
|
function AdminPage() {
|
||||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
||||||
const [tab, setTab] = useState<AdminTab>('events')
|
const [tab, setTab] = useState<AdminTab>('events')
|
||||||
@@ -324,8 +374,23 @@ function AdminPage() {
|
|||||||
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
|
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
|
||||||
if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" />
|
if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" />
|
||||||
if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
|
if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
|
||||||
const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const] : [])]
|
const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const, 'community' as const] : [])]
|
||||||
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={tab !== 'moderators' ? <>{events.data && events.data.length > 0 && <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></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events' && item !== 'moderators'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}</div>
|
const standaloneTab = tab === 'moderators' || tab === 'community'
|
||||||
|
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : tab === 'community' ? 'Community settings' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : tab === 'community' ? 'Manage the links shown to every community member.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={!standaloneTab ? <>{events.data && events.data.length > 0 && <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></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && !['events', 'moderators', 'community'].includes(item)} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : item === 'community' ? 'Community' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}{tab === 'community' && session.data?.account.role === 'admin' && <CommunitySettingsPanel />}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommunitySettingsPanel() {
|
||||||
|
const client = useQueryClient()
|
||||||
|
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
|
||||||
|
const [discordInviteUrl, setDiscordInviteUrl] = useState('')
|
||||||
|
useEffect(() => { if (settings.data) setDiscordInviteUrl(settings.data.discordInviteUrl) }, [settings.data])
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: () => demoMode ? Promise.resolve({ discordInviteUrl: discordInviteUrl.trim() }) : api.updateCommunitySettings(discordInviteUrl.trim()),
|
||||||
|
onSuccess: (saved) => client.setQueryData(['community-settings'], saved),
|
||||||
|
})
|
||||||
|
if (settings.isLoading) return <LoadingState label="Loading community settings…" />
|
||||||
|
if (settings.isError) return <ErrorState message={settings.error.message} retry={() => void settings.refetch()} />
|
||||||
|
return <form className="card community-settings" onSubmit={(event) => { event.preventDefault(); update.mutate() }}><div className="section-title"><div><h2>Community Discord</h2><p>This invite is shown in FAQ to signed-in players.</p></div><Gamepad2 /></div><label>Discord invite URL<input type="url" inputMode="url" placeholder="https://discord.gg/…" value={discordInviteUrl} onChange={(event) => setDiscordInviteUrl(event.target.value)} /></label><div className="form-footer"><p>{update.isError ? update.error.message : 'Leave empty to hide the invite button.'}</p><button className="button primary" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save settings'}</button></div></form>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModeratorManagement() {
|
function ModeratorManagement() {
|
||||||
@@ -478,7 +543,7 @@ function Participants({ eventId }: { eventId: string }) {
|
|||||||
if (rows.isLoading) return <LoadingState label="Loading participants…" />
|
if (rows.isLoading) return <LoadingState label="Loading participants…" />
|
||||||
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
|
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]))
|
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
|
||||||
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><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 || remove.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><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
|
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><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"><PlayerLink player={r.player} /><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 || remove.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><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
function BalanceView({ eventId }: { eventId: string }) {
|
function BalanceView({ eventId }: { eventId: string }) {
|
||||||
@@ -712,11 +777,11 @@ function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team
|
|||||||
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
|
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
|
||||||
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
|
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>{allowCaptainAssignment && <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>
|
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><PlayerLink player={player} /><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <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 PublicTeamCard({ team }: { team: Team }) {
|
function PublicTeamCard({ team }: { team: Team }) {
|
||||||
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></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>{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
|
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></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><PlayerLink player={player} />{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveSeriesPage() {
|
function LiveSeriesPage() {
|
||||||
@@ -807,11 +872,11 @@ function LiveSeriesPage() {
|
|||||||
function Scoreboard({ series }: { series: Series }) {
|
function Scoreboard({ series }: { series: Series }) {
|
||||||
const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') })
|
const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') })
|
||||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, enabled: controlPage, staleTime: Infinity })
|
const session = useQuery({ queryKey: ['session'], queryFn: api.session, enabled: controlPage, staleTime: Infinity })
|
||||||
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player.displayName
|
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player
|
||||||
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
|
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player
|
||||||
const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
|
const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
|
||||||
const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
|
const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
|
||||||
return <><TournamentStrip series={series} /><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>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
|
return <><TournamentStrip series={series} /><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 · <PlayerLink player={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 · <PlayerLink player={betaCaptain} /></> : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function TournamentStrip({ series }: { series: Series }) {
|
function TournamentStrip({ series }: { series: Series }) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const player: RawPlayer = {
|
|||||||
id: 'player-1',
|
id: 'player-1',
|
||||||
accountId: 'account-1',
|
accountId: 'account-1',
|
||||||
displayName: 'Nova',
|
displayName: 'Nova',
|
||||||
|
battleTag: 'Nova#1234',
|
||||||
ratings: { tank: 36, damage: 28, support: 24 },
|
ratings: { tank: 36, damage: 28, support: 24 },
|
||||||
preferredRoles: ['Tank'],
|
preferredRoles: ['Tank'],
|
||||||
preferredPlayerIds: ['player-2'],
|
preferredPlayerIds: ['player-2'],
|
||||||
@@ -19,6 +20,7 @@ describe('backend DTO adapters', () => {
|
|||||||
it('adapts backend role ratings without changing their values', () => {
|
it('adapts backend role ratings without changing their values', () => {
|
||||||
expect(adaptPlayer(player)).toMatchObject({
|
expect(adaptPlayer(player)).toMatchObject({
|
||||||
displayName: 'Nova',
|
displayName: 'Nova',
|
||||||
|
battleTag: 'Nova#1234',
|
||||||
ratings: { tank: 36, damage: 28, support: 24 },
|
ratings: { tank: 36, damage: 28, support: 24 },
|
||||||
preferredRoles: ['tank'],
|
preferredRoles: ['tank'],
|
||||||
preferredPlayerIds: ['player-2'],
|
preferredPlayerIds: ['player-2'],
|
||||||
@@ -118,10 +120,38 @@ describe('actual backend routes', () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}))
|
}))
|
||||||
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
|
await api.updateRatings('Nova Prime', 'Nova#5678', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
|
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
|
||||||
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
|
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
|
||||||
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
|
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
|
||||||
|
expect(fetchMock.mock.calls[0][1]?.body).toContain('"displayName":"Nova Prime"')
|
||||||
|
expect(fetchMock.mock.calls[0][1]?.body).toContain('"battleTag":"Nova#5678"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads public profile history and updates community settings', async () => {
|
||||||
|
const publicProfile = {
|
||||||
|
player: {
|
||||||
|
id: player.id,
|
||||||
|
displayName: player.displayName,
|
||||||
|
battleTag: player.battleTag,
|
||||||
|
ratings: player.ratings,
|
||||||
|
isGuest: false,
|
||||||
|
},
|
||||||
|
summary: { seriesPlayed: 3, seriesWon: 2, seriesLost: 1, winRate: 2 / 3, mapsPlayed: 7, mapsWon: 4, mapsLost: 2, mapsDrawn: 1 },
|
||||||
|
recentMatches: [{ seriesId: 'series-1', eventId: 'event-1', eventName: 'Cup', eventDate: '2026-07-18T00:00:00Z', ownTeamId: 'a', ownTeamName: 'A', opponentTeamId: 'b', opponentTeamName: 'B', ownScore: 2, opponentScore: 1, outcome: 'win' }],
|
||||||
|
}
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify(publicProfile), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||||
|
const profile = await api.playerProfile(player.id)
|
||||||
|
expect(profile.stats.mapsDrawn).toBe(1)
|
||||||
|
expect(profile.recentMatches[0].outcome).toBe('win')
|
||||||
|
await api.updateCommunitySettings('https://discord.gg/test')
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('/api/players/player-1')
|
||||||
|
expect(fetchMock.mock.calls[1]).toEqual(['/api/community/settings', expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }),
|
||||||
|
})])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends backend RSVP enum values to the player-specific route', async () => {
|
it('sends backend RSVP enum values to the player-specific route', async () => {
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ export interface Player {
|
|||||||
preferredPlayerIds: string[]
|
preferredPlayerIds: string[]
|
||||||
avoidedPlayerIds: string[]
|
avoidedPlayerIds: string[]
|
||||||
}
|
}
|
||||||
|
export interface PlayerStats {
|
||||||
|
seriesPlayed: number
|
||||||
|
seriesWon: number
|
||||||
|
seriesLost: number
|
||||||
|
winRate: number
|
||||||
|
mapsPlayed: number
|
||||||
|
mapsWon: number
|
||||||
|
mapsLost: number
|
||||||
|
mapsDrawn: number
|
||||||
|
}
|
||||||
|
export interface RecentMatch {
|
||||||
|
seriesId: string
|
||||||
|
eventId: string
|
||||||
|
eventName: string
|
||||||
|
startedAt: string
|
||||||
|
teamId: string
|
||||||
|
teamName: string
|
||||||
|
opponentTeamId: string
|
||||||
|
opponentTeamName: string
|
||||||
|
scoreFor: number
|
||||||
|
scoreAgainst: number
|
||||||
|
outcome: 'win' | 'loss'
|
||||||
|
}
|
||||||
|
export interface PublicPlayerProfile extends Player {
|
||||||
|
isGuest: boolean
|
||||||
|
stats: PlayerStats
|
||||||
|
recentMatches: RecentMatch[]
|
||||||
|
}
|
||||||
|
export interface CommunitySettings { discordInviteUrl: string }
|
||||||
export interface Session {
|
export interface Session {
|
||||||
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
|
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
|
||||||
player: Player
|
player: Player
|
||||||
@@ -154,13 +183,36 @@ export interface RawAccount {
|
|||||||
}
|
}
|
||||||
export interface RawRatings { tank: number; damage: number; support: number }
|
export interface RawRatings { tank: number; damage: number; support: number }
|
||||||
export interface RawPlayer {
|
export interface RawPlayer {
|
||||||
id: string; accountId: string; displayName: string; ratings: RawRatings
|
id: string; accountId: string; displayName: string; battleTag?: string; ratings: RawRatings
|
||||||
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
|
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
|
||||||
preferredPlayerIds?: string[]
|
preferredPlayerIds?: string[]
|
||||||
avoidedPlayerIds?: string[]
|
avoidedPlayerIds?: string[]
|
||||||
createdAt: string; updatedAt: string
|
createdAt: string; updatedAt: string
|
||||||
}
|
}
|
||||||
export interface RawSession { account: RawAccount; player: RawPlayer }
|
export interface RawSession { account: RawAccount; player: RawPlayer }
|
||||||
|
export interface RawPlayerProfile {
|
||||||
|
player: {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
battleTag: string
|
||||||
|
ratings: RawRatings
|
||||||
|
isGuest: boolean
|
||||||
|
}
|
||||||
|
summary: PlayerStats
|
||||||
|
recentMatches: Array<{
|
||||||
|
seriesId: string
|
||||||
|
eventId: string
|
||||||
|
eventName: string
|
||||||
|
eventDate: string
|
||||||
|
ownTeamId: string
|
||||||
|
ownTeamName: string
|
||||||
|
opponentTeamId: string
|
||||||
|
opponentTeamName: string
|
||||||
|
ownScore: number
|
||||||
|
opponentScore: number
|
||||||
|
outcome: 'win' | 'loss'
|
||||||
|
}>
|
||||||
|
}
|
||||||
export interface RawEvent {
|
export interface RawEvent {
|
||||||
id: string; name: string; description: string; startsAt: string; endsAt: string
|
id: string; name: string; description: string; startsAt: string; endsAt: string
|
||||||
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
|
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
|
||||||
@@ -223,6 +275,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
|
|||||||
id: raw.id,
|
id: raw.id,
|
||||||
accountId: raw.accountId,
|
accountId: raw.accountId,
|
||||||
displayName: raw.displayName,
|
displayName: raw.displayName,
|
||||||
|
battleTag: raw.battleTag || undefined,
|
||||||
ratings,
|
ratings,
|
||||||
preferredRoles,
|
preferredRoles,
|
||||||
preferredPlayerIds: raw.preferredPlayerIds ?? [],
|
preferredPlayerIds: raw.preferredPlayerIds ?? [],
|
||||||
@@ -529,6 +582,7 @@ export const api = {
|
|||||||
profile: async () => adaptPlayer((await raw.session()).player),
|
profile: async () => adaptPlayer((await raw.session()).player),
|
||||||
updateRatings: async (
|
updateRatings: async (
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
battleTag: string,
|
||||||
ratings: Pick<Ratings, PlayerRole>,
|
ratings: Pick<Ratings, PlayerRole>,
|
||||||
preferredRoles: PlayerRole[],
|
preferredRoles: PlayerRole[],
|
||||||
preferredPlayerIds: string[],
|
preferredPlayerIds: string[],
|
||||||
@@ -538,13 +592,50 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
displayName,
|
displayName,
|
||||||
|
battleTag: battleTag.trim(),
|
||||||
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
|
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
|
||||||
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
|
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
|
||||||
preferredPlayerIds,
|
preferredPlayerIds,
|
||||||
avoidedPlayerIds,
|
avoidedPlayerIds,
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
|
playerProfile: async (playerId: string): Promise<PublicPlayerProfile> => {
|
||||||
|
const profile = await request<RawPlayerProfile>(`/players/${playerId}`)
|
||||||
|
return {
|
||||||
|
id: profile.player.id,
|
||||||
|
accountId: profile.player.isGuest ? undefined : 'linked',
|
||||||
|
displayName: profile.player.displayName,
|
||||||
|
battleTag: profile.player.battleTag || undefined,
|
||||||
|
ratings: {
|
||||||
|
tank: toRankOrdinal(profile.player.ratings.tank),
|
||||||
|
damage: toRankOrdinal(profile.player.ratings.damage),
|
||||||
|
support: toRankOrdinal(profile.player.ratings.support),
|
||||||
|
updatedAt: '',
|
||||||
|
},
|
||||||
|
preferredRoles: [],
|
||||||
|
preferredPlayerIds: [],
|
||||||
|
avoidedPlayerIds: [],
|
||||||
|
isGuest: profile.player.isGuest,
|
||||||
|
stats: profile.summary,
|
||||||
|
recentMatches: profile.recentMatches.map((match) => ({
|
||||||
|
seriesId: match.seriesId,
|
||||||
|
eventId: match.eventId,
|
||||||
|
eventName: match.eventName,
|
||||||
|
startedAt: match.eventDate,
|
||||||
|
teamId: match.ownTeamId,
|
||||||
|
teamName: match.ownTeamName,
|
||||||
|
opponentTeamId: match.opponentTeamId,
|
||||||
|
opponentTeamName: match.opponentTeamName,
|
||||||
|
scoreFor: match.ownScore,
|
||||||
|
scoreAgainst: match.opponentScore,
|
||||||
|
outcome: match.outcome,
|
||||||
|
})),
|
||||||
|
} satisfies PublicPlayerProfile
|
||||||
|
},
|
||||||
players: async () => (await raw.players()).map(adaptPlayer),
|
players: async () => (await raw.players()).map(adaptPlayer),
|
||||||
|
communitySettings: () => request<CommunitySettings>('/community/settings'),
|
||||||
|
updateCommunitySettings: (discordInviteUrl: string) =>
|
||||||
|
request<CommunitySettings>('/community/settings', { method: 'PUT', body: JSON.stringify({ discordInviteUrl }) }),
|
||||||
accounts: () => request<RawAccount[]>('/accounts'),
|
accounts: () => request<RawAccount[]>('/accounts'),
|
||||||
setModerator: (accountId: string, moderator: boolean) =>
|
setModerator: (accountId: string, moderator: boolean) =>
|
||||||
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),
|
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
BracketDraft,
|
BracketDraft,
|
||||||
MixEvent,
|
MixEvent,
|
||||||
Player,
|
Player,
|
||||||
|
PublicPlayerProfile,
|
||||||
Registration,
|
Registration,
|
||||||
Series,
|
Series,
|
||||||
Session,
|
Session,
|
||||||
@@ -307,3 +308,50 @@ export const demoBracketDraft: BracketDraft = {
|
|||||||
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
|
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const demoCommunitySettings = {
|
||||||
|
discordInviteUrl: 'https://discord.gg/mixmaker',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const demoPlayerProfiles: PublicPlayerProfile[] = players.map((player, index) => ({
|
||||||
|
...player,
|
||||||
|
isGuest: !player.accountId,
|
||||||
|
stats: {
|
||||||
|
seriesPlayed: 18 + index,
|
||||||
|
seriesWon: 11 + (index % 4),
|
||||||
|
seriesLost: 7 + (index % 3),
|
||||||
|
winRate: Math.round(((11 + (index % 4)) / (18 + index)) * 100),
|
||||||
|
mapsPlayed: 42 + index * 2,
|
||||||
|
mapsWon: 25 + index,
|
||||||
|
mapsLost: 14 + index,
|
||||||
|
mapsDrawn: 3,
|
||||||
|
},
|
||||||
|
recentMatches: [
|
||||||
|
{
|
||||||
|
seriesId: 'series-1',
|
||||||
|
eventId: 'event-3',
|
||||||
|
eventName: 'Summer Clash #4',
|
||||||
|
startedAt: daysFromNow(-2, 20),
|
||||||
|
teamId: 'team-alpha',
|
||||||
|
teamName: 'Ember Wolves',
|
||||||
|
opponentTeamId: 'team-beta',
|
||||||
|
opponentTeamName: 'Azure Phantoms',
|
||||||
|
scoreFor: 2,
|
||||||
|
scoreAgainst: 1,
|
||||||
|
outcome: 'win',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seriesId: 'series-2',
|
||||||
|
eventId: 'event-5',
|
||||||
|
eventName: 'Completed Friday Mix',
|
||||||
|
startedAt: daysFromNow(-7, 20),
|
||||||
|
teamId: 'team-alpha',
|
||||||
|
teamName: 'Ember Wolves',
|
||||||
|
opponentTeamId: 'team-beta',
|
||||||
|
opponentTeamName: 'Neon Foxes',
|
||||||
|
scoreFor: 1,
|
||||||
|
scoreAgainst: 2,
|
||||||
|
outcome: 'loss',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|||||||
@@ -88,6 +88,29 @@ const translations: Record<string, string> = {
|
|||||||
'Set your competitive ranks': 'Укажите соревновательные ранги',
|
'Set your competitive ranks': 'Укажите соревновательные ранги',
|
||||||
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
|
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
|
||||||
'Connected via Discord': 'Подключено через Discord',
|
'Connected via Discord': 'Подключено через Discord',
|
||||||
|
'Edit profile': 'Редактировать профиль',
|
||||||
|
'Loading player profile…': 'Загрузка профиля игрока…',
|
||||||
|
'This player profile could not be found.': 'Профиль этого игрока не найден.',
|
||||||
|
'Community player': 'Игрок сообщества',
|
||||||
|
'Discord player': 'Игрок Discord',
|
||||||
|
'Guest player': 'Гостевой игрок',
|
||||||
|
'Role ranks': 'Ранги по ролям',
|
||||||
|
'Current competitive ratings': 'Текущие соревновательные рейтинги',
|
||||||
|
'Competitive history and role ranks.': 'История матчей и ранги по ролям.',
|
||||||
|
'Player statistics': 'Статистика игрока',
|
||||||
|
'Series played': 'Серий сыграно',
|
||||||
|
'Series won': 'Серий выиграно',
|
||||||
|
'Series lost': 'Серий проиграно',
|
||||||
|
'Win rate': 'Процент побед',
|
||||||
|
'Map record': 'Статистика карт',
|
||||||
|
'Won': 'Победы',
|
||||||
|
'Lost': 'Поражения',
|
||||||
|
'Drawn': 'Ничьи',
|
||||||
|
'Recent matches': 'Недавние матчи',
|
||||||
|
'Latest completed series': 'Последние завершённые серии',
|
||||||
|
'No completed matches yet.': 'Завершённых матчей пока нет.',
|
||||||
|
'win': 'победа',
|
||||||
|
'loss': 'поражение',
|
||||||
'Role ratings': 'Рейтинги ролей',
|
'Role ratings': 'Рейтинги ролей',
|
||||||
'Use the full 1–100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
|
'Use the full 1–100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
|
||||||
'Competitive ranks': 'Соревновательные ранги',
|
'Competitive ranks': 'Соревновательные ранги',
|
||||||
@@ -120,6 +143,15 @@ const translations: Record<string, string> = {
|
|||||||
'Event setup': 'Настройки события',
|
'Event setup': 'Настройки события',
|
||||||
'Participants': 'Участники',
|
'Participants': 'Участники',
|
||||||
'Balance teams': 'Баланс команд',
|
'Balance teams': 'Баланс команд',
|
||||||
|
'Community': 'Сообщество',
|
||||||
|
'Community settings': 'Настройки сообщества',
|
||||||
|
'Manage the links shown to every community member.': 'Управляйте ссылками, доступными всем участникам сообщества.',
|
||||||
|
'Loading community settings…': 'Загрузка настроек сообщества…',
|
||||||
|
'Community Discord': 'Discord сообщества',
|
||||||
|
'This invite is shown in FAQ to signed-in players.': 'Это приглашение показывается авторизованным игрокам в FAQ.',
|
||||||
|
'Discord invite URL': 'Ссылка-приглашение Discord',
|
||||||
|
'Leave empty to hide the invite button.': 'Оставьте поле пустым, чтобы скрыть кнопку приглашения.',
|
||||||
|
'Save settings': 'Сохранить настройки',
|
||||||
'Event settings': 'Настройки события',
|
'Event settings': 'Настройки события',
|
||||||
'Scheduling and registration': 'Расписание и регистрация',
|
'Scheduling and registration': 'Расписание и регистрация',
|
||||||
'Published': 'Опубликовано',
|
'Published': 'Опубликовано',
|
||||||
@@ -148,6 +180,9 @@ const translations: Record<string, string> = {
|
|||||||
'Add participant': 'Добавить участника',
|
'Add participant': 'Добавить участника',
|
||||||
'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.',
|
'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.',
|
||||||
'Nickname': 'Ник',
|
'Nickname': 'Ник',
|
||||||
|
'BattleTag': 'BattleTag',
|
||||||
|
'Optional': 'Необязательно',
|
||||||
|
'Nicknames are unique across the community.': 'Ники уникальны в пределах сообщества.',
|
||||||
'Tank rank': 'Ранг танка',
|
'Tank rank': 'Ранг танка',
|
||||||
'Damage rank': 'Ранг урона',
|
'Damage rank': 'Ранг урона',
|
||||||
'Support rank': 'Ранг поддержки',
|
'Support rank': 'Ранг поддержки',
|
||||||
@@ -287,6 +322,9 @@ const translations: Record<string, string> = {
|
|||||||
'Rules and help': 'Правила и помощь',
|
'Rules and help': 'Правила и помощь',
|
||||||
'Frequently asked questions': 'Частые вопросы',
|
'Frequently asked questions': 'Частые вопросы',
|
||||||
'Everything players and captains need before the scrim starts.': 'Всё, что нужно знать игрокам и капитанам до начала скрима.',
|
'Everything players and captains need before the scrim starts.': 'Всё, что нужно знать игрокам и капитанам до начала скрима.',
|
||||||
|
'Join the community Discord': 'Присоединяйтесь к Discord сообщества',
|
||||||
|
'Discord is where event announcements, role updates, and match notifications arrive. Join the server and enable the bot roles you want to be notified about.': 'В Discord приходят анонсы событий, обновления ролей и уведомления о матчах. Присоединяйтесь к серверу и включите роли бота, уведомления от которых хотите получать.',
|
||||||
|
'Open Discord': 'Открыть Discord',
|
||||||
'How are teams balanced?': 'Как балансируются команды?',
|
'How are teams balanced?': 'Как балансируются команды?',
|
||||||
'The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.': 'Сервер собирает команды 1 танк / 2 урона / 2 поддержки. Главная цель — баланс рангов. Предпочитаемые роли, напарники и до трёх avoid-игроков являются мягкими пожеланиями и не важнее честного корректного состава.',
|
'The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.': 'Сервер собирает команды 1 танк / 2 урона / 2 поддержки. Главная цель — баланс рангов. Предпочитаемые роли, напарники и до трёх avoid-игроков являются мягкими пожеланиями и не важнее честного корректного состава.',
|
||||||
'How does registration work?': 'Как работает регистрация?',
|
'How does registration work?': 'Как работает регистрация?',
|
||||||
@@ -312,6 +350,7 @@ function translate(value: string) {
|
|||||||
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
|
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
|
||||||
[/^(\d+) going$/, (m) => `${m[1]} идут`],
|
[/^(\d+) going$/, (m) => `${m[1]} идут`],
|
||||||
[/^(\d+) responses$/, (m) => `${m[1]} ответов`],
|
[/^(\d+) responses$/, (m) => `${m[1]} ответов`],
|
||||||
|
[/^(\d+) maps played$/, (m) => `${m[1]} карт сыграно`],
|
||||||
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
|
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
|
||||||
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
|
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
|
||||||
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],
|
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],
|
||||||
|
|||||||
@@ -152,6 +152,33 @@ main { min-height: calc(100vh - 72px); }
|
|||||||
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
|
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
|
||||||
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
|
.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; }
|
.profile-banner h2 { font-size: 21px; }.profile-banner p { color: var(--muted); margin: 3px 0 10px; font-size: 12px; }
|
||||||
|
.player-link { color: #f5f7f9; font-weight: 750; transition: color .15s; }
|
||||||
|
.player-link:hover { color: var(--orange); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
.identity-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; padding-bottom: 22px; }
|
||||||
|
.identity-fields label,.community-settings label { display: grid; gap: 7px; color: #b6bcc4; font-size: 11px; font-weight: 700; }
|
||||||
|
.identity-fields label small { color: var(--muted); font-weight: 500; }
|
||||||
|
.identity-fields > p { grid-column: 1/-1; color: var(--muted); font-size: 10px; }
|
||||||
|
.player-profile-page { max-width: 1050px; }
|
||||||
|
.profile-identity { display: flex; align-items: center; gap: 18px; padding: 22px; margin-bottom: 14px; }
|
||||||
|
.profile-identity h2 { font-size: 22px; }.profile-identity p { margin: 4px 0 9px; color: var(--muted); font-size: 12px; }
|
||||||
|
.public-profile-grid { display: grid; grid-template-columns: 1fr 1.65fr; gap: 14px; }
|
||||||
|
.profile-ranks { padding: 20px; }.profile-ranks .section-title { margin-top: 0; }
|
||||||
|
.profile-rank { display: flex; align-items: center; gap: 11px; padding: 12px 0; border-top: 1px solid var(--border); }
|
||||||
|
.profile-rank > span:last-child { display: grid; gap: 3px; }.profile-rank small { color: var(--muted); font-size: 9px; text-transform: uppercase; }.profile-rank strong { font-size: 14px; }
|
||||||
|
.profile-stat-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 10px; }
|
||||||
|
.profile-stat-grid > div { min-height: 120px; padding: 20px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||||
|
.profile-stat-grid span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }.profile-stat-grid strong { font-size: 32px; }
|
||||||
|
.profile-stat-grid .highlight { border-color: #71401f; background: linear-gradient(145deg,var(--orange-soft),var(--surface)); }.profile-stat-grid .highlight strong { color: var(--orange); }
|
||||||
|
.map-record,.recent-matches { margin-top: 14px; padding: 20px; }.map-record .section-title,.recent-matches .section-title { margin-top: 0; }
|
||||||
|
.map-record > div:last-child { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
|
||||||
|
.map-record > div:last-child span { padding: 14px; display: flex; align-items: baseline; gap: 8px; border: 1px solid var(--border); border-radius: 7px; color: var(--muted); font-size: 10px; }.map-record > div:last-child strong { color: #fff; font-size: 23px; }
|
||||||
|
.recent-match { min-height: 72px; padding: 12px 0; display: grid; grid-template-columns: minmax(180px,1fr) minmax(300px,1.4fr) auto; align-items: center; gap: 16px; border-top: 1px solid var(--border); }
|
||||||
|
.recent-match > div:first-child { display: flex; align-items: center; gap: 10px; }.recent-match > div:first-child > span { display: grid; gap: 4px; }.recent-match small { color: var(--muted); font-size: 9px; }
|
||||||
|
.recent-match-score { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; text-align: center; }.recent-match-score span { color: var(--muted); font-size: 10px; }.recent-match-score strong { font-size: 20px; }.recent-match-score i { padding: 0 5px; color: var(--muted); font-style: normal; }
|
||||||
|
.recent-match > svg { color: var(--orange); }
|
||||||
|
.community-faq { grid-column: 1/-1; min-height: 140px; border-color: #334073; background: linear-gradient(135deg,#171d3c,var(--surface)); }
|
||||||
|
.community-faq > span svg { color: #7289da; width: 20px; height: 20px; }.community-faq .button { margin-top: 14px; }
|
||||||
|
.community-settings { max-width: 720px; padding: 24px; }.community-settings .section-title { margin-top: 0; }.community-settings .section-title > svg { color: #7289da; width: 25px; height: 25px; }
|
||||||
.ratings-form { padding: 28px; }
|
.ratings-form { padding: 28px; }
|
||||||
.ratings-form > .section-title { margin-top: 0; }
|
.ratings-form > .section-title { margin-top: 0; }
|
||||||
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; }
|
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; }
|
||||||
@@ -315,7 +342,8 @@ textarea { min-height: 85px; resize: vertical; }
|
|||||||
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
|
.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,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
|
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
|
||||||
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
|
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
|
||||||
.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%; }
|
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.identity-fields,.public-profile-grid { grid-template-columns: 1fr; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
|
||||||
|
.profile-stat-grid { grid-template-columns: repeat(2,1fr); }.profile-stat-grid > div { min-height: 100px; padding: 15px; }.map-record > div:last-child { grid-template-columns: 1fr; }.recent-match { grid-template-columns: 1fr auto; }.recent-match-score { grid-column: 1/-1; grid-row: 2; }.recent-match > svg { grid-column: 2; grid-row: 1; }
|
||||||
.teammate-grid { grid-template-columns: repeat(2,1fr); }
|
.teammate-grid { grid-template-columns: repeat(2,1fr); }
|
||||||
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
|
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; 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%; }
|
.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%; }
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
|
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
|
||||||
На странице события после выбора баланса публично показываются сохранённые составы команд. В live-control участник получает короткий звуковой сигнал, когда после действия соперника сервер передаёт ход его команде; первая загрузка страницы звук не запускает.
|
На странице события после выбора баланса публично показываются сохранённые составы команд. В live-control участник получает короткий звуковой сигнал, когда после действия соперника сервер передаёт ход его команде; первая загрузка страницы звук не запускает.
|
||||||
|
Добавлены профили всех игроков с уникальным nickname, необязательным BattleTag, текущими рангами, статистикой завершённых Bo3/карт и историей последних матчей. Имена в составах и списках ведут в профиль; player-facing UI больше не использует Discord username.
|
||||||
|
Admin-only Community settings хранит Discord invite URL. FAQ объясняет Discord-процесс, работу бота и показывает настроенную ссылку; Moderator не может её изменять.
|
||||||
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
|
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
|
||||||
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
|
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
|
||||||
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
|
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
|
||||||
@@ -41,6 +43,8 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
|||||||
- новый микс автоматически анонсируется в настроенном Discord-канале со ссылкой на регистрацию; `@everyone` можно отключить при создании.
|
- новый микс автоматически анонсируется в настроенном Discord-канале со ссылкой на регистрацию; `@everyone` можно отключить при создании.
|
||||||
- подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла.
|
- подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла.
|
||||||
- явные RSVP автоматически проецируются в локализованные event-specific Discord-роли ответа и статуса.
|
- явные RSVP автоматически проецируются в локализованные event-specific Discord-роли ответа и статуса.
|
||||||
|
- игроки используют уникальные nickname, могут указать BattleTag и открывать профили друг друга со статистикой и историей матчей;
|
||||||
|
- Discord invite для FAQ настраивается только Admin.
|
||||||
|
|
||||||
## Исходный регламент
|
## Исходный регламент
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ Application-слой управляет единым versioned workflow собы
|
|||||||
|
|
||||||
Глобальные роли — Admin, Moderator и Player; Captain назначается staff-пользователем для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin и Moderator управляют событиями, участниками, составами и сериями; только Admin может назначать или снимать Moderator. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс.
|
Глобальные роли — Admin, Moderator и Player; Captain назначается staff-пользователем для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin и Moderator управляют событиями, участниками, составами и сериями; только Admin может назначать или снимать Moderator. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс.
|
||||||
|
|
||||||
|
### Публичные профили и статистика
|
||||||
|
|
||||||
|
Authenticated `GET /players/{id}` возвращает consumer-safe read model без teammate preferences и avoid-списка: nickname, BattleTag, ранги, агрегат серии/карт и десять последних матчей. Агрегатор находится в domain и не зависит от PostgreSQL; adapter загружает завершённые Event, Series и финальные Team rosters. Corrections сворачиваются до эффективного результата, draw учитывается отдельно. Отдельная таблица статистики не нужна для текущего размера сообщества.
|
||||||
|
|
||||||
|
Nickname хранится как `Player.DisplayName`, нормализуется и защищён case-insensitive unique index. Discord username остаётся атрибутом Account; player-facing UI использует nickname. BattleTag является необязательным публичным полем.
|
||||||
|
|
||||||
### Календарь и участие
|
### Календарь и участие
|
||||||
|
|
||||||
События хранятся в UTC и отображаются в локальном часовом поясе пользователя. REST API отдаёт будущие и прошедшие события и статусы Going/Maybe/NotGoing. Изменения регистрации публикуются через realtime-канал, чтобы Admin сразу видел актуальный список. При изменении статуса за другого игрока API сохраняет actor ID и источник `admin`, а UI показывает, кем сделана отметка.
|
События хранятся в UTC и отображаются в локальном часовом поясе пользователя. REST API отдаёт будущие и прошедшие события и статусы Going/Maybe/NotGoing. Изменения регистрации публикуются через realtime-канал, чтобы Admin сразу видел актуальный список. При изменении статуса за другого игрока API сохраняет actor ID и источник `admin`, а UI показывает, кем сделана отметка.
|
||||||
@@ -118,6 +124,8 @@ Application-слой управляет единым versioned workflow собы
|
|||||||
|
|
||||||
Интеграция опциональна и отключена, если bot token и channel ID не заданы. Bot token доступен только API-контейнеру; OAuth client secret по-прежнему используется исключительно для входа. Для анонсов не нужны Gateway intents или постоянно открытое Gateway-соединение.
|
Интеграция опциональна и отключена, если bot token и channel ID не заданы. Bot token доступен только API-контейнеру; OAuth client secret по-прежнему используется исключительно для входа. Для анонсов не нужны Gateway intents или постоянно открытое Gateway-соединение.
|
||||||
|
|
||||||
|
Глобальная Discord invite URL хранится в `community_settings`. Authenticated FAQ читает её через REST; обновление разрешено только Admin и записывается в аудит. Это пользовательская ссылка сообщества и не заменяет секретные Discord bot/guild/channel настройки окружения.
|
||||||
|
|
||||||
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
|
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
|
||||||
|
|
||||||
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. Для связанного пользователя вне guild job повторяется раз в пять минут, поэтому после его вступления актуальные назначения появляются без Gateway listener; guest без Discord ID не создаёт бесконечных retries. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID`.
|
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. Для связанного пользователя вне guild job повторяется раз в пять минут, поэтому после его вступления актуальные назначения появляются без Gateway listener; guest без Discord ID не создаёт бесконечных retries. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID`.
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
### Player
|
### Player
|
||||||
|
|
||||||
Игрок связан с Discord-аккаунтом и имеет отображаемое имя и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 до Champion 1: Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster, Champion, каждый с дивизионами 5 → 1. Для вычислений значения преобразуются в ordinal 1–40.
|
Игрок связан с Discord-аккаунтом либо создан как guest и имеет уникальный без учёта регистра nickname длиной 2–32 символа. Discord username используется только для учётной записи и не является публичным игровым именем. Связанный игрок может дополнительно указать публичный BattleTag в формате `Name#1234`.
|
||||||
|
|
||||||
Игрок редактирует ранги самостоятельно; Admin может видеть время последнего изменения. Игрок выбирает любое число предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков для avoid. Предпочтения и avoid являются мягкими пожеланиями, а не гарантией состава; один игрок не может одновременно находиться в обоих списках.
|
Игрок редактирует nickname, BattleTag и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 → Champion 1 и внутри представляется ordinal 1–40. Игрок выбирает любое число предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков для avoid. Предпочтения и avoid являются мягкими пожеланиями, а не гарантией состава; один игрок не может одновременно находиться в обоих списках.
|
||||||
|
|
||||||
|
Авторизованные пользователи могут открыть read-only профиль любого Player, включая guest: текущие ранги, число завершённых Bo3, победы, win rate, карты W/L/D и последние матчи. Статистика вычисляется из завершённых Event/Series и эффективных результатов после corrections. Для существующей модели участие определяется финальным сохранённым составом Event; reserve не получает матч, а emergency substitution относит историю к финальному roster.
|
||||||
|
|
||||||
### Account
|
### Account
|
||||||
|
|
||||||
@@ -24,6 +26,10 @@
|
|||||||
|
|
||||||
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Параметр команды создания определяет, сопровождается ли анонс уведомлением `@everyone`; он не является сохраняемым состоянием Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
|
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Параметр команды создания определяет, сопровождается ли анонс уведомлением `@everyone`; он не является сохраняемым состоянием Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
|
||||||
|
|
||||||
|
### CommunitySettings
|
||||||
|
|
||||||
|
Singleton-настройки сообщества содержат ссылку-приглашение на Discord, которую FAQ показывает авторизованным игрокам. Читать настройки может любой авторизованный пользователь, изменять ссылку — только Admin, не Moderator; изменение сохраняет автора в audit log. Пустая ссылка скрывает кнопку приглашения.
|
||||||
|
|
||||||
### Discord-роли состава
|
### Discord-роли состава
|
||||||
|
|
||||||
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
|
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
## Этап 5 — удобство сообщества
|
## Этап 5 — удобство сообщества
|
||||||
|
|
||||||
- пресеты регламентов;
|
- пресеты регламентов;
|
||||||
- статистика игроков и команд;
|
- [x] публичные профили игроков, статистика Bo3/карт и история последних матчей;
|
||||||
|
- статистика команд;
|
||||||
- повторная жеребьёвка с защитой от одинаковых составов;
|
- повторная жеребьёвка с защитой от одинаковых составов;
|
||||||
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
|
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
|
||||||
- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing;
|
- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing;
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
|
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
|
||||||
- [x] отдельные member-list группы для команд и зарегистрированных участников;
|
- [x] отдельные member-list группы для команд и зарегистрированных участников;
|
||||||
- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей;
|
- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей;
|
||||||
|
- [x] уникальные nickname, BattleTag и настраиваемая Admin ссылка на Discord в FAQ;
|
||||||
- создание и очистка временных командных голосовых каналов;
|
- создание и очистка временных командных голосовых каналов;
|
||||||
- импорт данных и резервное копирование.
|
- импорт данных и резервное копирование.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ security:
|
|||||||
tags:
|
tags:
|
||||||
- name: Auth
|
- name: Auth
|
||||||
- name: Players
|
- name: Players
|
||||||
|
- name: Community
|
||||||
- name: Events
|
- name: Events
|
||||||
- name: Teams
|
- name: Teams
|
||||||
- name: Series
|
- name: Series
|
||||||
@@ -110,6 +111,56 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Player"
|
$ref: "#/components/schemas/Player"
|
||||||
|
/api/players/{playerId}:
|
||||||
|
get:
|
||||||
|
tags: [Players]
|
||||||
|
operationId: getPlayerProfile
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/PlayerId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Public-safe player profile, aggregate statistics, and recent completed matches
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PlayerProfile"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
/api/community/settings:
|
||||||
|
get:
|
||||||
|
tags: [Community]
|
||||||
|
operationId: getCommunitySettings
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Community settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CommunitySettings"
|
||||||
|
put:
|
||||||
|
tags: [Community]
|
||||||
|
operationId: updateCommunitySettings
|
||||||
|
description: Admin only. Moderators are forbidden.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [discordInviteUrl]
|
||||||
|
properties:
|
||||||
|
discordInviteUrl:
|
||||||
|
type: string
|
||||||
|
description: Empty, or an official discord.gg / discord.com/invite HTTPS URL.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated community settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CommunitySettings"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/Forbidden"
|
||||||
/api/accounts:
|
/api/accounts:
|
||||||
get:
|
get:
|
||||||
tags: [Admin]
|
tags: [Admin]
|
||||||
@@ -960,7 +1011,7 @@ components:
|
|||||||
description: Bronze 5 = 1, Champion 1 = 40
|
description: Bronze 5 = 1, Champion 1 = 40
|
||||||
Player:
|
Player:
|
||||||
type: object
|
type: object
|
||||||
required: [id, accountId, displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt]
|
required: [id, accountId, displayName, battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt]
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -970,6 +1021,12 @@ components:
|
|||||||
format: uuid
|
format: uuid
|
||||||
displayName:
|
displayName:
|
||||||
type: string
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 32
|
||||||
|
battleTag:
|
||||||
|
type: string
|
||||||
|
pattern: '^(|[^#\s]{2,20}#[0-9]{3,12})$'
|
||||||
|
description: Optional; an empty string clears the BattleTag.
|
||||||
ratings:
|
ratings:
|
||||||
$ref: "#/components/schemas/Ratings"
|
$ref: "#/components/schemas/Ratings"
|
||||||
preferredRoles:
|
preferredRoles:
|
||||||
@@ -1000,6 +1057,11 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
displayName:
|
displayName:
|
||||||
type: string
|
type: string
|
||||||
|
minLength: 2
|
||||||
|
maxLength: 32
|
||||||
|
battleTag:
|
||||||
|
type: string
|
||||||
|
description: Empty string clears the BattleTag.
|
||||||
ratings:
|
ratings:
|
||||||
$ref: "#/components/schemas/Ratings"
|
$ref: "#/components/schemas/Ratings"
|
||||||
preferredRoles:
|
preferredRoles:
|
||||||
@@ -1017,6 +1079,58 @@ components:
|
|||||||
maxItems: 3
|
maxItems: 3
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
PublicPlayer:
|
||||||
|
type: object
|
||||||
|
required: [id, displayName, battleTag, ratings, isGuest]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
displayName: { type: string, minLength: 2, maxLength: 32 }
|
||||||
|
battleTag: { type: string }
|
||||||
|
ratings: { $ref: "#/components/schemas/Ratings" }
|
||||||
|
isGuest: { type: boolean }
|
||||||
|
PlayerStatsSummary:
|
||||||
|
type: object
|
||||||
|
required: [seriesPlayed, seriesWon, seriesLost, winRate, mapsPlayed, mapsWon, mapsLost, mapsDrawn]
|
||||||
|
properties:
|
||||||
|
seriesPlayed: { type: integer }
|
||||||
|
seriesWon: { type: integer }
|
||||||
|
seriesLost: { type: integer }
|
||||||
|
winRate: { type: number, minimum: 0, maximum: 1 }
|
||||||
|
mapsPlayed: { type: integer }
|
||||||
|
mapsWon: { type: integer }
|
||||||
|
mapsLost: { type: integer }
|
||||||
|
mapsDrawn: { type: integer }
|
||||||
|
PlayerMatchHistory:
|
||||||
|
type: object
|
||||||
|
required: [seriesId, eventId, eventName, eventDate, ownTeamId, ownTeamName, opponentTeamId, opponentTeamName, ownScore, opponentScore, outcome]
|
||||||
|
properties:
|
||||||
|
seriesId: { type: string }
|
||||||
|
eventId: { type: string }
|
||||||
|
eventName: { type: string }
|
||||||
|
eventDate: { type: string, format: date-time }
|
||||||
|
ownTeamId: { type: string }
|
||||||
|
ownTeamName: { type: string }
|
||||||
|
opponentTeamId: { type: string }
|
||||||
|
opponentTeamName: { type: string }
|
||||||
|
ownScore: { type: integer }
|
||||||
|
opponentScore: { type: integer }
|
||||||
|
outcome: { type: string, enum: [win, loss] }
|
||||||
|
PlayerProfile:
|
||||||
|
type: object
|
||||||
|
required: [player, summary, recentMatches]
|
||||||
|
properties:
|
||||||
|
player: { $ref: "#/components/schemas/PublicPlayer" }
|
||||||
|
summary: { $ref: "#/components/schemas/PlayerStatsSummary" }
|
||||||
|
recentMatches:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/PlayerMatchHistory" }
|
||||||
|
CommunitySettings:
|
||||||
|
type: object
|
||||||
|
required: [discordInviteUrl, updatedAt, updatedBy]
|
||||||
|
properties:
|
||||||
|
discordInviteUrl: { type: string }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
|
updatedBy: { type: string }
|
||||||
EventInput:
|
EventInput:
|
||||||
type: object
|
type: object
|
||||||
required: [name, startsAt, endsAt, registrationDeadline]
|
required: [name, startsAt, endsAt, registrationDeadline]
|
||||||
|
|||||||
Reference in New Issue
Block a user