Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
This commit is contained in:
255
backend/internal/domain/balancer.go
Normal file
255
backend/internal/domain/balancer.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BalanceCandidate struct {
|
||||
Teams []Team `json:"teams"`
|
||||
Reserve []string `json:"reserve"`
|
||||
Score int `json:"score"`
|
||||
Explanation []string `json:"explanation"`
|
||||
}
|
||||
|
||||
// Balance deterministically creates three alternatives. Players are assigned to
|
||||
// their strongest role while preserving the 1/2/2 composition of every team.
|
||||
func Balance(eventID string, players []Player) ([]BalanceCandidate, error) {
|
||||
if len(players) < 10 {
|
||||
return nil, fmt.Errorf("%w: at least ten players are required", ErrInvalid)
|
||||
}
|
||||
base := slices.Clone(players)
|
||||
slices.SortFunc(base, func(a, b Player) int {
|
||||
if a.ID < b.ID {
|
||||
return -1
|
||||
}
|
||||
if a.ID > b.ID {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
roleOrders := [][]Role{
|
||||
{Tank, Damage, Support},
|
||||
{Tank, Support, Damage},
|
||||
{Damage, Tank, Support},
|
||||
{Damage, Support, Tank},
|
||||
{Support, Tank, Damage},
|
||||
{Support, Damage, Tank},
|
||||
}
|
||||
all := make([]BalanceCandidate, 0, len(roleOrders)*2)
|
||||
for orderIndex, order := range roleOrders {
|
||||
for offset := 0; offset < 2; offset++ {
|
||||
all = append(all, buildCandidate(eventID, base, orderIndex*2+offset, order, offset))
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(all, func(a, b BalanceCandidate) int { return a.Score - b.Score })
|
||||
result := make([]BalanceCandidate, 0, 3)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range all {
|
||||
signature := candidateSignature(candidate)
|
||||
if seen[signature] {
|
||||
continue
|
||||
}
|
||||
seen[signature] = true
|
||||
result = append(result, candidate)
|
||||
if len(result) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildCandidate(eventID string, players []Player, variant int, roleOrder []Role, offset int) BalanceCandidate {
|
||||
teamCount := len(players) / 5
|
||||
used := teamCount * 5
|
||||
c := BalanceCandidate{Reserve: make([]string, 0, len(players)-used)}
|
||||
for _, p := range players[used:] {
|
||||
c.Reserve = append(c.Reserve, p.ID)
|
||||
}
|
||||
active := slices.Clone(players[:used])
|
||||
for i := 0; i < teamCount; i++ {
|
||||
c.Teams = append(c.Teams, Team{
|
||||
ID: fmt.Sprintf("%s-v%d-team-%d", eventID, variant+1, i+1),
|
||||
EventID: eventID,
|
||||
Name: fmt.Sprintf("Team %d", i+1),
|
||||
})
|
||||
}
|
||||
for _, role := range roleOrder {
|
||||
slices.SortStableFunc(active, func(a, b Player) int {
|
||||
ar, br := rating(a, role), rating(b, role)
|
||||
if ar != br {
|
||||
return br - ar
|
||||
}
|
||||
if a.ID < b.ID {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
perTeam := 2
|
||||
if role == Tank {
|
||||
perTeam = 1
|
||||
}
|
||||
count := teamCount * perTeam
|
||||
selected := slices.Clone(active[:count])
|
||||
active = active[count:]
|
||||
for i, p := range selected {
|
||||
teamIndex := (i + offset) % teamCount
|
||||
if (i/teamCount)%2 == 1 {
|
||||
teamIndex = teamCount - 1 - teamIndex
|
||||
if teamIndex < 0 {
|
||||
teamIndex += teamCount
|
||||
}
|
||||
}
|
||||
c.Teams[teamIndex].Slots = append(c.Teams[teamIndex].Slots, Slot{PlayerID: p.ID, Role: role, Rating: rating(p, role)})
|
||||
}
|
||||
}
|
||||
optimizeCandidate(c.Teams, players)
|
||||
c.Score = balanceScore(c.Teams, players)
|
||||
preferredRoles, preferredTeammates := preferenceStats(c.Teams, players)
|
||||
c.Explanation = []string{
|
||||
fmt.Sprintf("%d preferred role assignments satisfied", preferredRoles),
|
||||
fmt.Sprintf("%d preferred teammate choices satisfied", preferredTeammates),
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func balanceScore(teams []Team, players []Player) int {
|
||||
playerByID := make(map[string]Player, len(players))
|
||||
for _, player := range players {
|
||||
playerByID[player.ID] = player
|
||||
}
|
||||
minTotal, maxTotal := math.MaxInt, 0
|
||||
roleTotals := map[Role][]int{Tank: {}, Damage: {}, Support: {}}
|
||||
weakRolePenalty := 0
|
||||
preferredRolePenalty := 0
|
||||
teamByPlayer := make(map[string]int)
|
||||
for teamIndex, team := range teams {
|
||||
total := 0
|
||||
perRole := map[Role]int{}
|
||||
for _, slot := range team.Slots {
|
||||
total += slot.Rating
|
||||
perRole[slot.Role] += slot.Rating
|
||||
player := playerByID[slot.PlayerID]
|
||||
teamByPlayer[slot.PlayerID] = teamIndex
|
||||
strongest := max(player.Ratings.Tank, player.Ratings.Damage, player.Ratings.Support)
|
||||
weakRolePenalty += strongest - slot.Rating
|
||||
if len(player.PreferredRoles) > 0 && !slices.Contains(player.PreferredRoles, slot.Role) {
|
||||
preferredRolePenalty += 12
|
||||
}
|
||||
}
|
||||
minTotal = min(minTotal, total)
|
||||
maxTotal = max(maxTotal, total)
|
||||
for role := range roleTotals {
|
||||
roleTotals[role] = append(roleTotals[role], perRole[role])
|
||||
}
|
||||
}
|
||||
preferredTeammatePenalty := 0
|
||||
for _, player := range players {
|
||||
teamIndex, active := teamByPlayer[player.ID]
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
for _, preferredID := range player.PreferredPlayerIDs {
|
||||
if preferredTeam, preferredActive := teamByPlayer[preferredID]; preferredActive && preferredTeam != teamIndex {
|
||||
preferredTeammatePenalty += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty
|
||||
for _, totals := range roleTotals {
|
||||
minimum, maximum := math.MaxInt, 0
|
||||
for _, total := range totals {
|
||||
minimum = min(minimum, total)
|
||||
maximum = max(maximum, total)
|
||||
}
|
||||
score += (maximum - minimum) * 2
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func optimizeCandidate(teams []Team, players []Player) {
|
||||
for iteration := 0; iteration < 20; iteration++ {
|
||||
bestScore := balanceScore(teams, players)
|
||||
bestA, bestB, bestSlotA, bestSlotB := -1, -1, -1, -1
|
||||
for teamA := 0; teamA < len(teams); teamA++ {
|
||||
for teamB := teamA + 1; teamB < len(teams); teamB++ {
|
||||
for slotA := range teams[teamA].Slots {
|
||||
for slotB := range teams[teamB].Slots {
|
||||
if teams[teamA].Slots[slotA].Role != teams[teamB].Slots[slotB].Role {
|
||||
continue
|
||||
}
|
||||
teams[teamA].Slots[slotA], teams[teamB].Slots[slotB] = teams[teamB].Slots[slotB], teams[teamA].Slots[slotA]
|
||||
score := balanceScore(teams, players)
|
||||
teams[teamA].Slots[slotA], teams[teamB].Slots[slotB] = teams[teamB].Slots[slotB], teams[teamA].Slots[slotA]
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestA, bestB, bestSlotA, bestSlotB = teamA, teamB, slotA, slotB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestA < 0 {
|
||||
return
|
||||
}
|
||||
teams[bestA].Slots[bestSlotA], teams[bestB].Slots[bestSlotB] = teams[bestB].Slots[bestSlotB], teams[bestA].Slots[bestSlotA]
|
||||
}
|
||||
}
|
||||
|
||||
func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
playerByID := make(map[string]Player, len(players))
|
||||
teamByPlayer := make(map[string]int)
|
||||
roleByPlayer := make(map[string]Role)
|
||||
for _, player := range players {
|
||||
playerByID[player.ID] = player
|
||||
}
|
||||
for teamIndex, team := range teams {
|
||||
for _, slot := range team.Slots {
|
||||
teamByPlayer[slot.PlayerID] = teamIndex
|
||||
roleByPlayer[slot.PlayerID] = slot.Role
|
||||
}
|
||||
}
|
||||
roleMatches, teammateMatches := 0, 0
|
||||
for playerID, teamIndex := range teamByPlayer {
|
||||
player := playerByID[playerID]
|
||||
if len(player.PreferredRoles) > 0 && slices.Contains(player.PreferredRoles, roleByPlayer[playerID]) {
|
||||
roleMatches++
|
||||
}
|
||||
for _, preferredID := range player.PreferredPlayerIDs {
|
||||
if preferredTeam, ok := teamByPlayer[preferredID]; ok && preferredTeam == teamIndex {
|
||||
teammateMatches++
|
||||
}
|
||||
}
|
||||
}
|
||||
return roleMatches, teammateMatches
|
||||
}
|
||||
|
||||
func candidateSignature(candidate BalanceCandidate) string {
|
||||
parts := make([]string, 0, len(candidate.Teams))
|
||||
for _, team := range candidate.Teams {
|
||||
slots := slices.Clone(team.Slots)
|
||||
slices.SortFunc(slots, func(a, b Slot) int {
|
||||
return strings.Compare(string(a.Role)+a.PlayerID, string(b.Role)+b.PlayerID)
|
||||
})
|
||||
var values []string
|
||||
for _, slot := range slots {
|
||||
values = append(values, string(slot.Role)+":"+slot.PlayerID)
|
||||
}
|
||||
parts = append(parts, strings.Join(values, ","))
|
||||
}
|
||||
slices.Sort(parts)
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func rating(p Player, role Role) int {
|
||||
switch role {
|
||||
case Tank:
|
||||
return p.Ratings.Tank
|
||||
case Damage:
|
||||
return p.Ratings.Damage
|
||||
default:
|
||||
return p.Ratings.Support
|
||||
}
|
||||
}
|
||||
97
backend/internal/domain/balancer_test.go
Normal file
97
backend/internal/domain/balancer_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBalanceProducesDeterministicFullTeamsAndReserve(t *testing.T) {
|
||||
var players []Player
|
||||
for i := 0; i < 23; i++ {
|
||||
players = append(players, Player{
|
||||
ID: fmt.Sprintf("p%02d", i),
|
||||
Ratings: Ratings{
|
||||
Tank: 5 + i%36,
|
||||
Damage: 5 + (i*3)%36,
|
||||
Support: 5 + (i*7)%36,
|
||||
},
|
||||
})
|
||||
}
|
||||
first, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := Balance("event", players)
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatal("balancer output is not deterministic")
|
||||
}
|
||||
if len(first) != 3 {
|
||||
t.Fatalf("got %d candidates", len(first))
|
||||
}
|
||||
for _, candidate := range first {
|
||||
if len(candidate.Teams) != 4 || len(candidate.Reserve) != 3 {
|
||||
t.Fatalf("unexpected allocation: %d teams, %d reserve", len(candidate.Teams), len(candidate.Reserve))
|
||||
}
|
||||
for _, team := range candidate.Teams {
|
||||
counts := map[Role]int{}
|
||||
for _, slot := range team.Slots {
|
||||
counts[slot.Role]++
|
||||
}
|
||||
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
|
||||
t.Fatalf("invalid composition: %#v", counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceKeepsOddNumberOfFullTeams(t *testing.T) {
|
||||
players := make([]Player, 15)
|
||||
for i := range players {
|
||||
players[i] = Player{
|
||||
ID: fmt.Sprintf("p%02d", i),
|
||||
Ratings: Ratings{Tank: 10 + i, Damage: 15 + i%20, Support: 8 + (i*3)%30},
|
||||
}
|
||||
}
|
||||
candidates, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if len(candidate.Teams) != 3 || len(candidate.Reserve) != 0 {
|
||||
t.Fatalf("expected three full teams, got %d teams and %d reserve", len(candidate.Teams), len(candidate.Reserve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
players := make([]Player, 10)
|
||||
for i := range players {
|
||||
players[i] = Player{
|
||||
ID: fmt.Sprintf("p%02d", i),
|
||||
Ratings: Ratings{Tank: 20, Damage: 20, Support: 20},
|
||||
}
|
||||
}
|
||||
players[0].PreferredRoles = []Role{Support}
|
||||
players[0].PreferredPlayerIDs = []string{"p01"}
|
||||
candidates, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
best := candidates[0]
|
||||
var teamForP0, teamForP1 int = -1, -1
|
||||
var roleForP0 Role
|
||||
for teamIndex, team := range best.Teams {
|
||||
for _, slot := range team.Slots {
|
||||
switch slot.PlayerID {
|
||||
case "p00":
|
||||
teamForP0, roleForP0 = teamIndex, slot.Role
|
||||
case "p01":
|
||||
teamForP1 = teamIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
if teamForP0 != teamForP1 || roleForP0 != Support {
|
||||
t.Fatalf("preferences were not reflected in best candidate: role=%s teams=%d/%d", roleForP0, teamForP0, teamForP1)
|
||||
}
|
||||
}
|
||||
48
backend/internal/domain/draft_test.go
Normal file
48
backend/internal/domain/draft_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMapDraftAlternatesAndLeavesOneMap(t *testing.T) {
|
||||
d, err := NewMapDraft([]string{"A", "B", "C"}, "red", [2]string{"red", "blue"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("blue", "A", "actor", time.Now()); err == nil {
|
||||
t.Fatal("accepted out-of-turn ban")
|
||||
}
|
||||
if err := d.Ban("red", "A", "actor", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("blue", "B", "actor", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if selected, ok := d.Selected(); !ok || selected != "C" {
|
||||
t.Fatalf("selected=%q ok=%v", selected, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroDraftRoleAndRepeatedBanRules(t *testing.T) {
|
||||
heroes := []Hero{{Name: "DVa", Role: Tank}, {Name: "Sigma", Role: Tank}, {Name: "Tracer", Role: Damage}, {Name: "Ana", Role: Support}}
|
||||
d, err := NewHeroDraft(heroes, [2]string{"a", "b"}, "a", 2, map[string][]string{"a": {"Ana"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("a", "Ana", "x", time.Now()); err == nil {
|
||||
t.Fatal("accepted team's repeated series ban")
|
||||
}
|
||||
if err := d.Ban("a", "DVa", "x", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Ban("b", "Ana", "y", time.Now()); err != nil {
|
||||
t.Fatal("opponent should be allowed to ban Ana:", err)
|
||||
}
|
||||
if err := d.Ban("a", "Sigma", "x", time.Now()); err == nil {
|
||||
t.Fatal("accepted same-role bans by one team")
|
||||
}
|
||||
if err := d.Ban("a", "Tracer", "x", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
518
backend/internal/domain/model.go
Normal file
518
backend/internal/domain/model.go
Normal file
@@ -0,0 +1,518 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("invalid input")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrDraftComplete = errors.New("draft is complete")
|
||||
)
|
||||
|
||||
type ID string
|
||||
type GlobalRole string
|
||||
|
||||
const (
|
||||
RolePlayer GlobalRole = "player"
|
||||
RoleAdmin GlobalRole = "admin"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
DiscordID string `json:"discordId"`
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Role GlobalRole `json:"role"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (a Account) IsAdmin() bool { return a.Role == RoleAdmin }
|
||||
|
||||
type Ratings struct {
|
||||
Tank int `json:"tank"`
|
||||
Damage int `json:"damage"`
|
||||
Support int `json:"support"`
|
||||
}
|
||||
|
||||
const (
|
||||
MinCompetitiveRank = 1 // Bronze 5
|
||||
MaxCompetitiveRank = 40 // Champion 1
|
||||
)
|
||||
|
||||
func (r Ratings) Validate() error {
|
||||
if r.Tank < MinCompetitiveRank || r.Tank > MaxCompetitiveRank ||
|
||||
r.Damage < MinCompetitiveRank || r.Damage > MaxCompetitiveRank ||
|
||||
r.Support < MinCompetitiveRank || r.Support > MaxCompetitiveRank {
|
||||
return fmt.Errorf("%w: ranks must be between Bronze 5 and Champion 1", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Player struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Ratings Ratings `json:"ratings"`
|
||||
PreferredRoles []Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (p Player) ValidatePreferences() error {
|
||||
if len(p.PreferredPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
||||
}
|
||||
seenRoles := map[Role]bool{}
|
||||
for _, role := range p.PreferredRoles {
|
||||
if (role != Tank && role != Damage && role != Support) || seenRoles[role] {
|
||||
return fmt.Errorf("%w: invalid preferred roles", ErrInvalid)
|
||||
}
|
||||
seenRoles[role] = true
|
||||
}
|
||||
seenPlayers := map[string]bool{}
|
||||
for _, playerID := range p.PreferredPlayerIDs {
|
||||
if playerID == "" || playerID == p.ID || seenPlayers[playerID] {
|
||||
return fmt.Errorf("%w: invalid preferred teammates", ErrInvalid)
|
||||
}
|
||||
seenPlayers[playerID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
RegistrationDeadline time.Time `json:"registrationDeadline"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (e Event) Validate() error {
|
||||
if strings.TrimSpace(e.Name) == "" || e.StartsAt.IsZero() || !e.EndsAt.After(e.StartsAt) ||
|
||||
e.RegistrationDeadline.IsZero() || e.RegistrationDeadline.After(e.StartsAt) {
|
||||
return fmt.Errorf("%w: event needs a name and a valid UTC interval", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RSVPStatus string
|
||||
|
||||
const (
|
||||
Going RSVPStatus = "Going"
|
||||
Maybe RSVPStatus = "Maybe"
|
||||
NotGoing RSVPStatus = "NotGoing"
|
||||
)
|
||||
|
||||
type RSVPSource string
|
||||
|
||||
const (
|
||||
SourcePlayer RSVPSource = "player"
|
||||
SourceAdmin RSVPSource = "admin"
|
||||
)
|
||||
|
||||
type RSVP struct {
|
||||
EventID string `json:"eventId"`
|
||||
PlayerID string `json:"playerId"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
Status RSVPStatus `json:"status"`
|
||||
Source RSVPSource `json:"source"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (r RSVP) Validate() error {
|
||||
if r.Status != Going && r.Status != Maybe && r.Status != NotGoing {
|
||||
return fmt.Errorf("%w: unknown RSVP status", ErrInvalid)
|
||||
}
|
||||
if r.EventID == "" || r.PlayerID == "" || r.ActorAccountID == "" {
|
||||
return fmt.Errorf("%w: RSVP identifiers are required", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
PlayerID string `json:"playerId"`
|
||||
Role Role `json:"role"`
|
||||
Rating int `json:"rating"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
CaptainPlayerID string `json:"captainPlayerId"`
|
||||
Slots []Slot `json:"slots"`
|
||||
}
|
||||
|
||||
func (t *Team) AssignCaptain(playerID string) error {
|
||||
if !slices.ContainsFunc(t.Slots, func(s Slot) bool { return s.PlayerID == playerID }) {
|
||||
return fmt.Errorf("%w: captain must be on the team", ErrInvalid)
|
||||
}
|
||||
t.CaptainPlayerID = playerID
|
||||
return nil
|
||||
}
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
Tank Role = "Tank"
|
||||
Damage Role = "Damage"
|
||||
Support Role = "Support"
|
||||
)
|
||||
|
||||
type Hero struct {
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type Ruleset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MapPools [][]string `json:"mapPools"`
|
||||
Heroes []Hero `json:"heroes"`
|
||||
HeroBansPerTeam int `json:"heroBansPerTeam"`
|
||||
BestOf int `json:"bestOf"`
|
||||
InitialMapBanner int `json:"initialMapBanner"`
|
||||
InitialHeroBanner int `json:"initialHeroBanner"`
|
||||
}
|
||||
|
||||
func (r Ruleset) Validate() error {
|
||||
if r.BestOf < 1 || r.BestOf%2 == 0 || r.HeroBansPerTeam < 0 || r.HeroBansPerTeam > 3 || len(r.MapPools) == 0 {
|
||||
return fmt.Errorf("%w: invalid ruleset", ErrInvalid)
|
||||
}
|
||||
for _, pool := range r.MapPools {
|
||||
if len(pool) == 0 {
|
||||
return fmt.Errorf("%w: empty map pool", ErrInvalid)
|
||||
}
|
||||
}
|
||||
for _, hero := range r.Heroes {
|
||||
if strings.TrimSpace(hero.Name) == "" || (hero.Role != Tank && hero.Role != Damage && hero.Role != Support) {
|
||||
return fmt.Errorf("%w: invalid hero catalog", ErrInvalid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CoinToss struct {
|
||||
Seed string `json:"seed"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
PerformedAt time.Time `json:"performedAt"`
|
||||
}
|
||||
|
||||
func TossCoin(teamA, teamB, seed string, now time.Time) (CoinToss, error) {
|
||||
if teamA == "" || teamB == "" || teamA == teamB || seed == "" {
|
||||
return CoinToss{}, fmt.Errorf("%w: two teams and a seed are required", ErrInvalid)
|
||||
}
|
||||
var n int64
|
||||
for _, r := range seed {
|
||||
n = n*31 + int64(r)
|
||||
}
|
||||
winner := teamA
|
||||
if rand.New(rand.NewSource(n)).Intn(2) == 1 { // deterministic and auditable
|
||||
winner = teamB
|
||||
}
|
||||
return CoinToss{Seed: seed, WinnerTeamID: winner, PerformedAt: now}, nil
|
||||
}
|
||||
|
||||
type DraftAction struct {
|
||||
TeamID string `json:"teamId"`
|
||||
Value string `json:"value"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type MapDraft struct {
|
||||
Pool []string `json:"pool"`
|
||||
Banned []string `json:"banned"`
|
||||
FirstTeamID string `json:"firstTeamId"`
|
||||
TeamIDs [2]string `json:"teamIds"`
|
||||
Actions []DraftAction `json:"actions"`
|
||||
}
|
||||
|
||||
func NewMapDraft(pool []string, firstTeam string, teams [2]string) (*MapDraft, error) {
|
||||
if len(pool) < 1 || teams[0] == teams[1] || (firstTeam != teams[0] && firstTeam != teams[1]) {
|
||||
return nil, fmt.Errorf("%w: invalid map draft", ErrInvalid)
|
||||
}
|
||||
return &MapDraft{Pool: slices.Clone(pool), FirstTeamID: firstTeam, TeamIDs: teams}, nil
|
||||
}
|
||||
|
||||
func (d *MapDraft) NextTeam() string {
|
||||
if len(d.Actions)%2 == 0 {
|
||||
return d.FirstTeamID
|
||||
}
|
||||
if d.FirstTeamID == d.TeamIDs[0] {
|
||||
return d.TeamIDs[1]
|
||||
}
|
||||
return d.TeamIDs[0]
|
||||
}
|
||||
|
||||
func (d *MapDraft) Ban(teamID, name, actor string, at time.Time) error {
|
||||
if len(d.Pool)-len(d.Banned) <= 1 {
|
||||
return ErrDraftComplete
|
||||
}
|
||||
if teamID != d.NextTeam() {
|
||||
return fmt.Errorf("%w: wrong team turn", ErrConflict)
|
||||
}
|
||||
if !slices.Contains(d.Pool, name) || slices.Contains(d.Banned, name) {
|
||||
return fmt.Errorf("%w: map unavailable", ErrInvalid)
|
||||
}
|
||||
d.Banned = append(d.Banned, name)
|
||||
d.Actions = append(d.Actions, DraftAction{TeamID: teamID, Value: name, ActorAccountID: actor, At: at})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *MapDraft) Selected() (string, bool) {
|
||||
if len(d.Pool)-len(d.Banned) != 1 {
|
||||
return "", false
|
||||
}
|
||||
for _, m := range d.Pool {
|
||||
if !slices.Contains(d.Banned, m) {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
type HeroDraft struct {
|
||||
Heroes []Hero `json:"heroes"`
|
||||
TeamIDs [2]string `json:"teamIds"`
|
||||
FirstTeamID string `json:"firstTeamId"`
|
||||
BansPerTeam int `json:"bansPerTeam"`
|
||||
SeriesBans map[string][]string `json:"seriesBans"`
|
||||
CurrentBans []DraftAction `json:"currentBans"`
|
||||
CurrentRoles map[string][]Role `json:"currentRoles"`
|
||||
}
|
||||
|
||||
func NewHeroDraft(heroes []Hero, teams [2]string, first string, count int, previous map[string][]string) (*HeroDraft, error) {
|
||||
if teams[0] == teams[1] || (first != teams[0] && first != teams[1]) || count < 0 {
|
||||
return nil, fmt.Errorf("%w: invalid hero draft", ErrInvalid)
|
||||
}
|
||||
if previous == nil {
|
||||
previous = map[string][]string{}
|
||||
}
|
||||
return &HeroDraft{Heroes: slices.Clone(heroes), TeamIDs: teams, FirstTeamID: first, BansPerTeam: count, SeriesBans: previous, CurrentRoles: map[string][]Role{}}, nil
|
||||
}
|
||||
|
||||
func (d *HeroDraft) NextTeam() string {
|
||||
if len(d.CurrentBans)%2 == 0 {
|
||||
return d.FirstTeamID
|
||||
}
|
||||
if d.FirstTeamID == d.TeamIDs[0] {
|
||||
return d.TeamIDs[1]
|
||||
}
|
||||
return d.TeamIDs[0]
|
||||
}
|
||||
|
||||
func (d *HeroDraft) Complete() bool { return len(d.CurrentBans) >= d.BansPerTeam*2 }
|
||||
|
||||
func (d *HeroDraft) Ban(teamID, heroName, actor string, at time.Time) error {
|
||||
if d.Complete() {
|
||||
return ErrDraftComplete
|
||||
}
|
||||
if teamID != d.NextTeam() {
|
||||
return fmt.Errorf("%w: wrong team turn", ErrConflict)
|
||||
}
|
||||
var hero *Hero
|
||||
for i := range d.Heroes {
|
||||
if d.Heroes[i].Name == heroName {
|
||||
hero = &d.Heroes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hero == nil || slices.ContainsFunc(d.CurrentBans, func(a DraftAction) bool { return a.Value == heroName }) {
|
||||
return fmt.Errorf("%w: hero unavailable", ErrInvalid)
|
||||
}
|
||||
if slices.Contains(d.SeriesBans[teamID], heroName) {
|
||||
return fmt.Errorf("%w: team cannot repeat its own series ban", ErrInvalid)
|
||||
}
|
||||
if slices.Contains(d.CurrentRoles[teamID], hero.Role) {
|
||||
return fmt.Errorf("%w: a team's bans must have different roles", ErrInvalid)
|
||||
}
|
||||
d.CurrentBans = append(d.CurrentBans, DraftAction{TeamID: teamID, Value: heroName, ActorAccountID: actor, At: at})
|
||||
d.CurrentRoles[teamID] = append(d.CurrentRoles[teamID], hero.Role)
|
||||
d.SeriesBans[teamID] = append(d.SeriesBans[teamID], heroName)
|
||||
return nil
|
||||
}
|
||||
|
||||
type MapOutcome string
|
||||
|
||||
const (
|
||||
TeamAWin MapOutcome = "TeamAWin"
|
||||
TeamBWin MapOutcome = "TeamBWin"
|
||||
Draw MapOutcome = "Draw"
|
||||
)
|
||||
|
||||
type MapResult struct {
|
||||
MapName string `json:"mapName"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
Outcome MapOutcome `json:"outcome"`
|
||||
RecordedAt time.Time `json:"recordedAt"`
|
||||
CorrectionOf int `json:"correctionOf,omitempty"`
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID string `json:"id"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
TeamAID string `json:"teamAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
BestOf int `json:"bestOf"`
|
||||
Results []MapResult `json:"results"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Series) RecordResult(result MapResult) error {
|
||||
if s.WinnerTeamID != "" {
|
||||
return fmt.Errorf("%w: series already complete", ErrConflict)
|
||||
}
|
||||
if err := validateMapResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
result.CorrectionOf = 0
|
||||
s.Results = append(s.Results, result)
|
||||
s.Version++
|
||||
s.recalculateWinner()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) CorrectResult(index int, result MapResult) error {
|
||||
if index < 0 || index >= len(s.Results) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := validateMapResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
result.CorrectionOf = index + 1
|
||||
s.Results = append(s.Results, result)
|
||||
s.Version++
|
||||
s.recalculateWinner()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMapResult(result MapResult) error {
|
||||
if result.MapName == "" || (result.Outcome != TeamAWin && result.Outcome != TeamBWin && result.Outcome != Draw) {
|
||||
return fmt.Errorf("%w: invalid map result", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) recalculateWinner() {
|
||||
s.WinnerTeamID = ""
|
||||
need := s.BestOf/2 + 1
|
||||
a, b := 0, 0
|
||||
superseded := make(map[int]bool)
|
||||
for _, result := range s.Results {
|
||||
if result.CorrectionOf > 0 {
|
||||
superseded[result.CorrectionOf] = true
|
||||
}
|
||||
}
|
||||
for i, r := range s.Results {
|
||||
if superseded[i+1] {
|
||||
continue
|
||||
}
|
||||
if r.Outcome == TeamAWin {
|
||||
a++
|
||||
} else if r.Outcome == TeamBWin {
|
||||
b++
|
||||
}
|
||||
}
|
||||
if a >= need {
|
||||
s.WinnerTeamID = s.TeamAID
|
||||
} else if b >= need {
|
||||
s.WinnerTeamID = s.TeamBID
|
||||
}
|
||||
}
|
||||
|
||||
type Tournament struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Rounds [][]Series `json:"rounds"`
|
||||
}
|
||||
|
||||
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {
|
||||
if len(teams) < 2 {
|
||||
return nil, fmt.Errorf("%w: tournament needs at least two teams", ErrInvalid)
|
||||
}
|
||||
t := &Tournament{ID: id, EventID: eventID, Name: name, TeamIDs: slices.Clone(teams)}
|
||||
bracketSize := 1
|
||||
for bracketSize < len(teams) {
|
||||
bracketSize *= 2
|
||||
}
|
||||
firstMatchCount := bracketSize / 2
|
||||
first := make([]Series, firstMatchCount)
|
||||
for i := range first {
|
||||
first[i] = Series{ID: fmt.Sprintf("%s-r1-m%d", id, i+1), TournamentID: id, TeamAID: teams[i], BestOf: 3}
|
||||
}
|
||||
for i := firstMatchCount; i < len(teams); i++ {
|
||||
first[i-firstMatchCount].TeamBID = teams[i]
|
||||
}
|
||||
for i := range first {
|
||||
if first[i].TeamBID == "" {
|
||||
first[i].WinnerTeamID = first[i].TeamAID
|
||||
}
|
||||
}
|
||||
t.Rounds = append(t.Rounds, first)
|
||||
for matches, round := firstMatchCount/2, 2; matches >= 1; matches, round = matches/2, round+1 {
|
||||
next := make([]Series, matches)
|
||||
for i := range next {
|
||||
next[i] = Series{ID: fmt.Sprintf("%s-r%d-m%d", id, round, i+1), TournamentID: id, BestOf: 3}
|
||||
}
|
||||
t.Rounds = append(t.Rounds, next)
|
||||
}
|
||||
for match := range first {
|
||||
if first[match].WinnerTeamID == "" {
|
||||
continue
|
||||
}
|
||||
target := &t.Rounds[1][match/2]
|
||||
if match%2 == 0 {
|
||||
target.TeamAID = first[match].WinnerTeamID
|
||||
} else {
|
||||
target.TeamBID = first[match].WinnerTeamID
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *Tournament) Advance(round, match int) error {
|
||||
if round < 0 || round >= len(t.Rounds) || match < 0 || match >= len(t.Rounds[round]) {
|
||||
return ErrNotFound
|
||||
}
|
||||
s := t.Rounds[round][match]
|
||||
if s.WinnerTeamID == "" {
|
||||
return fmt.Errorf("%w: series is not complete", ErrConflict)
|
||||
}
|
||||
if len(t.Rounds[round]) == 1 {
|
||||
t.WinnerTeamID = s.WinnerTeamID
|
||||
return nil
|
||||
}
|
||||
if len(t.Rounds) == round+1 {
|
||||
next := make([]Series, len(t.Rounds[round])/2)
|
||||
for i := range next {
|
||||
next[i] = Series{ID: fmt.Sprintf("%s-r%d-m%d", t.ID, round+2, i+1), TournamentID: t.ID, BestOf: 3}
|
||||
}
|
||||
t.Rounds = append(t.Rounds, next)
|
||||
}
|
||||
target := &t.Rounds[round+1][match/2]
|
||||
if match%2 == 0 {
|
||||
target.TeamAID = s.WinnerTeamID
|
||||
} else {
|
||||
target.TeamBID = s.WinnerTeamID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
backend/internal/domain/model_test.go
Normal file
60
backend/internal/domain/model_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRatingsAndCaptainInvariants(t *testing.T) {
|
||||
if err := (Ratings{Tank: 1, Damage: 40, Support: 20}).Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (Ratings{Tank: 0, Damage: 20, Support: 20}).Validate(); err == nil {
|
||||
t.Fatal("expected invalid rating")
|
||||
}
|
||||
team := Team{Slots: []Slot{{PlayerID: "p1", Role: Tank}}}
|
||||
if err := team.AssignCaptain("outsider"); err == nil {
|
||||
t.Fatal("outsider was assigned captain")
|
||||
}
|
||||
if err := team.AssignCaptain("p1"); err != nil || team.CaptainPlayerID != "p1" {
|
||||
t.Fatalf("valid captain rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerPreferenceLimits(t *testing.T) {
|
||||
player := Player{ID: "p1", PreferredRoles: []Role{Tank, Support}, PreferredPlayerIDs: []string{"p2", "p3", "p4"}}
|
||||
if err := player.ValidatePreferences(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player.PreferredPlayerIDs = append(player.PreferredPlayerIDs, "p5")
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected teammate preference limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesBestOfThree(t *testing.T) {
|
||||
s := Series{TeamAID: "a", TeamBID: "b", BestOf: 3}
|
||||
for _, outcome := range []MapOutcome{TeamAWin, Draw, TeamAWin} {
|
||||
if err := s.RecordResult(MapResult{MapName: "map", Outcome: outcome}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if s.WinnerTeamID != "a" || s.Version != 3 {
|
||||
t.Fatalf("unexpected completed series: %+v", s)
|
||||
}
|
||||
if err := s.RecordResult(MapResult{MapName: "late", Outcome: TeamBWin}); err == nil {
|
||||
t.Fatal("accepted result after completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesResultCorrectionPreservesHistoryAndRecalculatesWinner(t *testing.T) {
|
||||
s := Series{TeamAID: "a", TeamBID: "b", BestOf: 3}
|
||||
_ = s.RecordResult(MapResult{MapName: "one", Outcome: TeamAWin})
|
||||
_ = s.RecordResult(MapResult{MapName: "two", Outcome: TeamAWin})
|
||||
if s.WinnerTeamID != "a" {
|
||||
t.Fatal("series should initially be won by team A")
|
||||
}
|
||||
if err := s.CorrectResult(1, MapResult{MapName: "two", Outcome: TeamBWin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.WinnerTeamID != "" || len(s.Results) != 3 || s.Results[2].CorrectionOf != 2 {
|
||||
t.Fatalf("unexpected corrected series: %+v", s)
|
||||
}
|
||||
}
|
||||
45
backend/internal/domain/tournament_test.go
Normal file
45
backend/internal/domain/tournament_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSingleEliminationAdvancement(t *testing.T) {
|
||||
tr, err := NewTournament("cup", "event", "Night Cup", []string{"a", "b", "c", "d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Rounds[0][0].WinnerTeamID = "a"
|
||||
if err := tr.Advance(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.Rounds[0][1].WinnerTeamID = "d"
|
||||
if err := tr.Advance(0, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tr.Rounds[1][0]; got.TeamAID != "a" || got.TeamBID != "d" {
|
||||
t.Fatalf("unexpected final: %+v", got)
|
||||
}
|
||||
tr.Rounds[1][0].WinnerTeamID = "d"
|
||||
if err := tr.Advance(1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tr.WinnerTeamID != "d" {
|
||||
t.Fatalf("winner = %q", tr.WinnerTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTournamentCreatesByeForNonPowerOfTwo(t *testing.T) {
|
||||
tr, err := NewTournament("cup", "event", "Cup", []string{"a", "b", "c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tr.Rounds) != 2 || len(tr.Rounds[0]) != 2 {
|
||||
t.Fatalf("unexpected bracket shape: %+v", tr.Rounds)
|
||||
}
|
||||
bye := tr.Rounds[0][1]
|
||||
if bye.TeamAID != "b" || bye.TeamBID != "" || bye.WinnerTeamID != "b" {
|
||||
t.Fatalf("unexpected bye: %+v", bye)
|
||||
}
|
||||
if tr.Rounds[1][0].TeamBID != "b" {
|
||||
t.Fatalf("bye was not advanced: %+v", tr.Rounds[1][0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user