Add player profiles and community settings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,9 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -63,6 +65,7 @@ type Player struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
BattleTag string `json:"battleTag"`
|
||||
Ratings Ratings `json:"ratings"`
|
||||
PreferredRoles []Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
@@ -71,6 +74,21 @@ type Player struct {
|
||||
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 {
|
||||
if len(p.PreferredPlayerIDs) > 3 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user