Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
This commit is contained in:
@@ -107,10 +107,11 @@ func buildCandidate(eventID string, players []Player, variant int, roleOrder []R
|
||||
}
|
||||
optimizeCandidate(c.Teams, players)
|
||||
c.Score = balanceScore(c.Teams, players)
|
||||
preferredRoles, preferredTeammates := preferenceStats(c.Teams, players)
|
||||
preferredRoles, preferredTeammates, avoidedTeammates := preferenceStats(c.Teams, players)
|
||||
c.Explanation = []string{
|
||||
fmt.Sprintf("%d preferred role assignments satisfied", preferredRoles),
|
||||
fmt.Sprintf("%d preferred teammate choices satisfied", preferredTeammates),
|
||||
fmt.Sprintf("%d avoided teammate choices satisfied", avoidedTeammates),
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -146,6 +147,7 @@ func balanceScore(teams []Team, players []Player) int {
|
||||
}
|
||||
}
|
||||
preferredTeammatePenalty := 0
|
||||
avoidedTeammatePenalty := 0
|
||||
for _, player := range players {
|
||||
teamIndex, active := teamByPlayer[player.ID]
|
||||
if !active {
|
||||
@@ -156,8 +158,13 @@ func balanceScore(teams []Team, players []Player) int {
|
||||
preferredTeammatePenalty += 8
|
||||
}
|
||||
}
|
||||
for _, avoidedID := range player.AvoidedPlayerIDs {
|
||||
if avoidedTeam, avoidedActive := teamByPlayer[avoidedID]; avoidedActive && avoidedTeam == teamIndex {
|
||||
avoidedTeammatePenalty += 14
|
||||
}
|
||||
}
|
||||
}
|
||||
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty
|
||||
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty + avoidedTeammatePenalty
|
||||
for _, totals := range roleTotals {
|
||||
minimum, maximum := math.MaxInt, 0
|
||||
for _, total := range totals {
|
||||
@@ -198,7 +205,7 @@ func optimizeCandidate(teams []Team, players []Player) {
|
||||
}
|
||||
}
|
||||
|
||||
func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
func preferenceStats(teams []Team, players []Player) (int, int, int) {
|
||||
playerByID := make(map[string]Player, len(players))
|
||||
teamByPlayer := make(map[string]int)
|
||||
roleByPlayer := make(map[string]Role)
|
||||
@@ -211,7 +218,7 @@ func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
roleByPlayer[slot.PlayerID] = slot.Role
|
||||
}
|
||||
}
|
||||
roleMatches, teammateMatches := 0, 0
|
||||
roleMatches, teammateMatches, avoidedMatches := 0, 0, 0
|
||||
for playerID, teamIndex := range teamByPlayer {
|
||||
player := playerByID[playerID]
|
||||
if len(player.PreferredRoles) > 0 && slices.Contains(player.PreferredRoles, roleByPlayer[playerID]) {
|
||||
@@ -222,8 +229,13 @@ func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
teammateMatches++
|
||||
}
|
||||
}
|
||||
for _, avoidedID := range player.AvoidedPlayerIDs {
|
||||
if avoidedTeam, ok := teamByPlayer[avoidedID]; ok && avoidedTeam != teamIndex {
|
||||
avoidedMatches++
|
||||
}
|
||||
}
|
||||
}
|
||||
return roleMatches, teammateMatches
|
||||
return roleMatches, teammateMatches, avoidedMatches
|
||||
}
|
||||
|
||||
func candidateSignature(candidate BalanceCandidate) string {
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestBalanceKeepsOddNumberOfFullTeams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
func TestBalanceUsesSoftRoleTeammateAndAvoidPreferences(t *testing.T) {
|
||||
players := make([]Player, 10)
|
||||
for i := range players {
|
||||
players[i] = Player{
|
||||
@@ -74,12 +74,13 @@ func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
}
|
||||
players[0].PreferredRoles = []Role{Support}
|
||||
players[0].PreferredPlayerIDs = []string{"p01"}
|
||||
players[0].AvoidedPlayerIDs = []string{"p02"}
|
||||
candidates, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
best := candidates[0]
|
||||
var teamForP0, teamForP1 int = -1, -1
|
||||
var teamForP0, teamForP1, teamForP2 int = -1, -1, -1
|
||||
var roleForP0 Role
|
||||
for teamIndex, team := range best.Teams {
|
||||
for _, slot := range team.Slots {
|
||||
@@ -88,10 +89,12 @@ func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
teamForP0, roleForP0 = teamIndex, slot.Role
|
||||
case "p01":
|
||||
teamForP1 = teamIndex
|
||||
case "p02":
|
||||
teamForP2 = teamIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
if teamForP0 != teamForP1 || roleForP0 != Support {
|
||||
t.Fatalf("preferences were not reflected in best candidate: role=%s teams=%d/%d", roleForP0, teamForP0, teamForP1)
|
||||
if teamForP0 != teamForP1 || teamForP0 == teamForP2 || roleForP0 != Support {
|
||||
t.Fatalf("preferences were not reflected in best candidate: role=%s teams=%d/%d avoid=%d", roleForP0, teamForP0, teamForP1, teamForP2)
|
||||
}
|
||||
}
|
||||
|
||||
136
backend/internal/domain/event_workflow.go
Normal file
136
backend/internal/domain/event_workflow.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type EventState string
|
||||
|
||||
const (
|
||||
RegistrationOpen EventState = "RegistrationOpen"
|
||||
RegistrationClosed EventState = "RegistrationClosed"
|
||||
Balancing EventState = "Balancing"
|
||||
RostersDraft EventState = "RostersDraft"
|
||||
RostersConfirmed EventState = "RostersConfirmed"
|
||||
Live EventState = "Live"
|
||||
Completed EventState = "Completed"
|
||||
Cancelled EventState = "Cancelled"
|
||||
)
|
||||
|
||||
func (e *Event) Transition(from []EventState, to EventState, expectedVersion int) error {
|
||||
if e.Version != expectedVersion {
|
||||
return fmt.Errorf("%w: stale event version", ErrConflict)
|
||||
}
|
||||
if !slices.Contains(from, e.State) {
|
||||
return fmt.Errorf("%w: event is %s", ErrConflict, e.State)
|
||||
}
|
||||
e.State = to
|
||||
e.Version++
|
||||
return nil
|
||||
}
|
||||
|
||||
type RosterDraft struct {
|
||||
EventID string `json:"eventId"`
|
||||
Teams []Team `json:"teams"`
|
||||
Reserve []string `json:"reserve"`
|
||||
Version int `json:"version"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
func (r RosterDraft) Validate(requireCaptains bool) error {
|
||||
if r.EventID == "" || len(r.Teams) < 2 {
|
||||
return fmt.Errorf("%w: at least two teams are required", ErrInvalid)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, team := range r.Teams {
|
||||
if len(team.Slots) != 5 {
|
||||
return fmt.Errorf("%w: every team must contain five players", ErrInvalid)
|
||||
}
|
||||
counts := map[Role]int{}
|
||||
for _, slot := range team.Slots {
|
||||
if slot.PlayerID == "" || seen[slot.PlayerID] {
|
||||
return fmt.Errorf("%w: roster players must be unique", ErrInvalid)
|
||||
}
|
||||
seen[slot.PlayerID] = true
|
||||
counts[slot.Role]++
|
||||
}
|
||||
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
|
||||
return fmt.Errorf("%w: every team must use 1/2/2", ErrInvalid)
|
||||
}
|
||||
if requireCaptains && !slices.ContainsFunc(team.Slots, func(slot Slot) bool { return slot.PlayerID == team.CaptainPlayerID }) {
|
||||
return fmt.Errorf("%w: every team needs a captain", ErrInvalid)
|
||||
}
|
||||
}
|
||||
for _, playerID := range r.Reserve {
|
||||
if playerID == "" || seen[playerID] {
|
||||
return fmt.Errorf("%w: reserve players must be unique", ErrInvalid)
|
||||
}
|
||||
seen[playerID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RosterDraft) Swap(teamAID, playerAID, teamBID, playerBID string) error {
|
||||
if r.Confirmed {
|
||||
return fmt.Errorf("%w: rosters are locked", ErrConflict)
|
||||
}
|
||||
var a, b *Slot
|
||||
for teamIndex := range r.Teams {
|
||||
for slotIndex := range r.Teams[teamIndex].Slots {
|
||||
slot := &r.Teams[teamIndex].Slots[slotIndex]
|
||||
if r.Teams[teamIndex].ID == teamAID && slot.PlayerID == playerAID {
|
||||
a = slot
|
||||
}
|
||||
if r.Teams[teamIndex].ID == teamBID && slot.PlayerID == playerBID {
|
||||
b = slot
|
||||
}
|
||||
}
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if a.Role != b.Role {
|
||||
return fmt.Errorf("%w: only equal roles can be swapped", ErrInvalid)
|
||||
}
|
||||
a.PlayerID, b.PlayerID = b.PlayerID, a.PlayerID
|
||||
a.Rating, b.Rating = b.Rating, a.Rating
|
||||
r.Version++
|
||||
return r.Validate(false)
|
||||
}
|
||||
|
||||
func (r *RosterDraft) Substitute(teamID, outgoingID, reserveID string, rating int) error {
|
||||
if r.Confirmed {
|
||||
return fmt.Errorf("%w: rosters are locked", ErrConflict)
|
||||
}
|
||||
reserveIndex := slices.Index(r.Reserve, reserveID)
|
||||
if reserveIndex < 0 {
|
||||
return fmt.Errorf("%w: player is not in reserve", ErrInvalid)
|
||||
}
|
||||
for teamIndex := range r.Teams {
|
||||
if r.Teams[teamIndex].ID != teamID {
|
||||
continue
|
||||
}
|
||||
for slotIndex := range r.Teams[teamIndex].Slots {
|
||||
slot := &r.Teams[teamIndex].Slots[slotIndex]
|
||||
if slot.PlayerID == outgoingID {
|
||||
slot.PlayerID, slot.Rating = reserveID, rating
|
||||
r.Reserve[reserveIndex] = outgoingID
|
||||
if r.Teams[teamIndex].CaptainPlayerID == outgoingID {
|
||||
r.Teams[teamIndex].CaptainPlayerID = ""
|
||||
}
|
||||
r.Version++
|
||||
return r.Validate(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error {
|
||||
confirmed := r.Confirmed
|
||||
r.Confirmed = false
|
||||
err := r.Substitute(teamID, outgoingID, reserveID, rating)
|
||||
r.Confirmed = confirmed
|
||||
return err
|
||||
}
|
||||
@@ -64,6 +64,7 @@ type Player struct {
|
||||
Ratings Ratings `json:"ratings"`
|
||||
PreferredRoles []Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
AvoidedPlayerIDs []string `json:"avoidedPlayerIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -72,6 +73,9 @@ func (p Player) ValidatePreferences() error {
|
||||
if len(p.PreferredPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
||||
}
|
||||
if len(p.AvoidedPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three avoided teammates are allowed", ErrInvalid)
|
||||
}
|
||||
seenRoles := map[Role]bool{}
|
||||
for _, role := range p.PreferredRoles {
|
||||
if (role != Tank && role != Damage && role != Support) || seenRoles[role] {
|
||||
@@ -86,19 +90,31 @@ func (p Player) ValidatePreferences() error {
|
||||
}
|
||||
seenPlayers[playerID] = true
|
||||
}
|
||||
seenAvoids := map[string]bool{}
|
||||
for _, playerID := range p.AvoidedPlayerIDs {
|
||||
if playerID == "" || playerID == p.ID || seenAvoids[playerID] || seenPlayers[playerID] {
|
||||
return fmt.Errorf("%w: invalid avoided teammates", ErrInvalid)
|
||||
}
|
||||
seenAvoids[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"`
|
||||
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"`
|
||||
State EventState `json:"state"`
|
||||
Version int `json:"version"`
|
||||
RulesetID string `json:"rulesetId"`
|
||||
ActiveSeriesID string `json:"activeSeriesId"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
}
|
||||
|
||||
func (e Event) Validate() error {
|
||||
@@ -364,14 +380,27 @@ type MapResult struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
TeamAID string `json:"teamAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
BestOf int `json:"bestOf"`
|
||||
RulesetID string `json:"rulesetId"`
|
||||
Phase SeriesPhase `json:"phase"`
|
||||
CoinToss *CoinToss `json:"coinToss,omitempty"`
|
||||
MapDraft *MapDraft `json:"mapDraft,omitempty"`
|
||||
HeroDraft *HeroDraft `json:"heroDraft,omitempty"`
|
||||
CurrentMap string `json:"currentMap"`
|
||||
PlayedMaps []string `json:"playedMaps"`
|
||||
NextMapPickerID string `json:"nextMapPickerId"`
|
||||
AvailableMaps []string `json:"availableMaps"`
|
||||
Maps []SeriesMap `json:"maps"`
|
||||
SeriesBans map[string][]string `json:"seriesBans"`
|
||||
Audit []SeriesAction `json:"audit"`
|
||||
Results []MapResult `json:"results"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Series) RecordResult(result MapResult) error {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestRatingsAndCaptainInvariants(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPlayerPreferenceLimits(t *testing.T) {
|
||||
player := Player{ID: "p1", PreferredRoles: []Role{Tank, Support}, PreferredPlayerIDs: []string{"p2", "p3", "p4"}}
|
||||
player := Player{ID: "p1", PreferredRoles: []Role{Tank, Support}, PreferredPlayerIDs: []string{"p2", "p3", "p4"}, AvoidedPlayerIDs: []string{"p5", "p6", "p7"}}
|
||||
if err := player.ValidatePreferences(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -27,6 +27,15 @@ func TestPlayerPreferenceLimits(t *testing.T) {
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected teammate preference limit")
|
||||
}
|
||||
player.PreferredPlayerIDs = []string{"p2"}
|
||||
player.AvoidedPlayerIDs = append(player.AvoidedPlayerIDs, "p8")
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected avoided teammate limit")
|
||||
}
|
||||
player.AvoidedPlayerIDs = []string{"p2"}
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected preferred/avoided overlap rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesBestOfThree(t *testing.T) {
|
||||
|
||||
186
backend/internal/domain/series_fsm.go
Normal file
186
backend/internal/domain/series_fsm.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SeriesPhase string
|
||||
|
||||
const (
|
||||
CoinTossPending SeriesPhase = "CoinTossPending"
|
||||
MapBanPhase SeriesPhase = "MapBan"
|
||||
MapPickPhase SeriesPhase = "MapPick"
|
||||
HeroBanPhase SeriesPhase = "HeroBan"
|
||||
PlayingPhase SeriesPhase = "Playing"
|
||||
SeriesComplete SeriesPhase = "Completed"
|
||||
)
|
||||
|
||||
type SeriesAction struct {
|
||||
Kind string `json:"kind"`
|
||||
Summary string `json:"summary"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type SeriesMap struct {
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
HeroBans []DraftAction `json:"heroBans"`
|
||||
Result *MapResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func NewSeries(id, eventID, tournamentID string, teams [2]string, rules Ruleset) (*Series, error) {
|
||||
if id == "" || eventID == "" || teams[0] == "" || teams[1] == "" || teams[0] == teams[1] {
|
||||
return nil, fmt.Errorf("%w: invalid series", ErrInvalid)
|
||||
}
|
||||
if err := rules.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Series{
|
||||
ID: id, EventID: eventID, TournamentID: tournamentID,
|
||||
TeamAID: teams[0], TeamBID: teams[1], BestOf: rules.BestOf,
|
||||
RulesetID: rules.ID, Phase: CoinTossPending,
|
||||
SeriesBans: map[string][]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Series) Toss(seed, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != CoinTossPending {
|
||||
return fmt.Errorf("%w: coin toss is unavailable", ErrConflict)
|
||||
}
|
||||
toss, err := TossCoin(s.TeamAID, s.TeamBID, seed, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.CoinToss = &toss
|
||||
first := toss.WinnerTeamID
|
||||
if rules.InitialMapBanner == 1 {
|
||||
first = s.otherTeam(first)
|
||||
}
|
||||
draft, err := NewMapDraft(s.availablePool(rules, 0), first, [2]string{s.TeamAID, s.TeamBID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.MapDraft, s.Phase = draft, MapBanPhase
|
||||
s.Version++
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "coin_toss", Summary: "Coin toss won by " + toss.WinnerTeamID, TeamID: toss.WinnerTeamID, ActorAccountID: actor, At: now})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) BanMap(teamID, name, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != MapBanPhase || s.MapDraft == nil {
|
||||
return fmt.Errorf("%w: map ban is unavailable", ErrConflict)
|
||||
}
|
||||
if err := s.MapDraft.Ban(teamID, name, actor, now); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Version++
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "map_ban", Summary: name + " banned", TeamID: teamID, ActorAccountID: actor, At: now})
|
||||
selected, complete := s.MapDraft.Selected()
|
||||
if !complete {
|
||||
return nil
|
||||
}
|
||||
return s.startHeroDraft(selected, actor, now, rules)
|
||||
}
|
||||
|
||||
func (s *Series) PickMap(teamID, name, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != MapPickPhase {
|
||||
return fmt.Errorf("%w: map pick is unavailable", ErrConflict)
|
||||
}
|
||||
if teamID != s.NextMapPickerID {
|
||||
return fmt.Errorf("%w: only the previous map loser can pick", ErrConflict)
|
||||
}
|
||||
if !slices.Contains(s.AvailableMaps, name) {
|
||||
return fmt.Errorf("%w: map unavailable", ErrInvalid)
|
||||
}
|
||||
s.Version++
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: name + " picked", TeamID: teamID, ActorAccountID: actor, At: now})
|
||||
return s.startHeroDraft(name, actor, now, rules)
|
||||
}
|
||||
|
||||
func (s *Series) startHeroDraft(selected, actor string, now time.Time, rules Ruleset) error {
|
||||
s.CurrentMap = selected
|
||||
first := s.otherTeam(s.CoinToss.WinnerTeamID)
|
||||
if rules.InitialHeroBanner == 0 {
|
||||
first = s.CoinToss.WinnerTeamID
|
||||
}
|
||||
draft, err := NewHeroDraft(rules.Heroes, [2]string{s.TeamAID, s.TeamBID}, first, rules.HeroBansPerTeam, s.SeriesBans)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.HeroDraft, s.Phase = draft, HeroBanPhase
|
||||
s.NextMapPickerID, s.AvailableMaps = "", nil
|
||||
s.Maps = append(s.Maps, SeriesMap{Number: len(s.Maps) + 1, Name: selected})
|
||||
if len(s.Audit) == 0 || s.Audit[len(s.Audit)-1].Kind != "map_selected" {
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: selected + " selected", ActorAccountID: actor, At: now})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) BanHero(teamID, hero, actor string, now time.Time) error {
|
||||
if s.Phase != HeroBanPhase || s.HeroDraft == nil {
|
||||
return fmt.Errorf("%w: hero ban is unavailable", ErrConflict)
|
||||
}
|
||||
if err := s.HeroDraft.Ban(teamID, hero, actor, now); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Version++
|
||||
s.Maps[len(s.Maps)-1].HeroBans = slices.Clone(s.HeroDraft.CurrentBans)
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "hero_ban", Summary: hero + " banned", TeamID: teamID, ActorAccountID: actor, At: now})
|
||||
if s.HeroDraft.Complete() {
|
||||
s.SeriesBans = s.HeroDraft.SeriesBans
|
||||
s.Phase = PlayingPhase
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) RecordCurrentMap(outcome MapOutcome, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != PlayingPhase || s.CurrentMap == "" {
|
||||
return fmt.Errorf("%w: map result is unavailable", ErrConflict)
|
||||
}
|
||||
result := MapResult{MapName: s.CurrentMap, Outcome: outcome, ActorAccountID: actor, RecordedAt: now}
|
||||
if err := s.RecordResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
s.PlayedMaps = append(s.PlayedMaps, s.CurrentMap)
|
||||
s.Maps[len(s.Maps)-1].Result = &result
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "result", Summary: s.CurrentMap + ": " + string(outcome), ActorAccountID: actor, At: now})
|
||||
if s.WinnerTeamID != "" {
|
||||
s.Phase = SeriesComplete
|
||||
return nil
|
||||
}
|
||||
poolIndex := len(s.Maps)
|
||||
if poolIndex >= len(rules.MapPools) {
|
||||
poolIndex = len(rules.MapPools) - 1
|
||||
}
|
||||
switch outcome {
|
||||
case TeamAWin:
|
||||
s.NextMapPickerID = s.TeamBID
|
||||
case TeamBWin:
|
||||
s.NextMapPickerID = s.TeamAID
|
||||
default:
|
||||
// A draw has no loser, so the initial coin-toss loser receives the pick.
|
||||
s.NextMapPickerID = s.otherTeam(s.CoinToss.WinnerTeamID)
|
||||
}
|
||||
s.AvailableMaps = s.availablePool(rules, poolIndex)
|
||||
if len(s.AvailableMaps) == 0 {
|
||||
return fmt.Errorf("%w: no unplayed maps remain in pool", ErrInvalid)
|
||||
}
|
||||
s.MapDraft, s.HeroDraft, s.CurrentMap, s.Phase = nil, nil, "", MapPickPhase
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Series) availablePool(rules Ruleset, index int) []string {
|
||||
pool := slices.Clone(rules.MapPools[index])
|
||||
return slices.DeleteFunc(pool, func(name string) bool { return slices.Contains(s.PlayedMaps, name) })
|
||||
}
|
||||
|
||||
func (s Series) otherTeam(teamID string) string {
|
||||
if teamID == s.TeamAID {
|
||||
return s.TeamBID
|
||||
}
|
||||
return s.TeamAID
|
||||
}
|
||||
127
backend/internal/domain/workflow_test.go
Normal file
127
backend/internal/domain/workflow_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventWorkflowRejectsStaleVersion(t *testing.T) {
|
||||
event := Event{State: RegistrationOpen, Version: 2}
|
||||
if err := event.Transition([]EventState{RegistrationOpen}, RegistrationClosed, 1); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected stale version conflict, got %v", err)
|
||||
}
|
||||
if event.State != RegistrationOpen || event.Version != 2 {
|
||||
t.Fatal("stale transition mutated the event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) {
|
||||
roster := testRoster()
|
||||
if err := roster.Swap("a", "a-d1", "b", "b-s1"); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected cross-role swap rejection, got %v", err)
|
||||
}
|
||||
if err := roster.Swap("a", "a-d1", "b", "b-d1"); err != nil {
|
||||
t.Fatalf("same-role swap failed: %v", err)
|
||||
}
|
||||
if err := roster.Substitute("a", "b-d1", "reserve", 30); err != nil {
|
||||
t.Fatalf("reserve substitution failed: %v", err)
|
||||
}
|
||||
if roster.Reserve[0] != "b-d1" {
|
||||
t.Fatalf("displaced player was not returned to reserve: %#v", roster.Reserve)
|
||||
}
|
||||
if err := roster.Validate(false); err != nil {
|
||||
t.Fatalf("valid 1/2/2 roster rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) {
|
||||
rules := testRules()
|
||||
series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(100, 0).UTC()
|
||||
if err = series.Toss("seed", "admin", now, rules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for !func() bool { _, selected := series.MapDraft.Selected(); return selected }() {
|
||||
name := firstAvailableMap(series.MapDraft)
|
||||
if err = series.BanMap(series.MapDraft.NextTeam(), name, "admin", now, rules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if series.Phase != HeroBanPhase {
|
||||
t.Fatalf("expected hero bans, got %s", series.Phase)
|
||||
}
|
||||
for _, hero := range []string{"Tank A", "Tank B", "Damage A", "Damage B"} {
|
||||
if err = series.BanHero(series.HeroDraft.NextTeam(), hero, "admin", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if series.Phase != PlayingPhase {
|
||||
t.Fatalf("expected playing phase, got %s", series.Phase)
|
||||
}
|
||||
if err = series.RecordCurrentMap(TeamAWin, "admin", now, rules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if series.Phase != MapPickPhase || len(series.PlayedMaps) != 1 || series.NextMapPickerID != "b" {
|
||||
t.Fatalf("next map was not prepared: phase=%s maps=%v", series.Phase, series.PlayedMaps)
|
||||
}
|
||||
if contains(series.AvailableMaps, series.PlayedMaps[0]) {
|
||||
t.Fatal("played map remained available in a later pool")
|
||||
}
|
||||
nextMap := series.AvailableMaps[0]
|
||||
if err = series.PickMap("a", nextMap, "admin", now, rules); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("winner was allowed to pick the next map: %v", err)
|
||||
}
|
||||
if err = series.PickMap("b", nextMap, "admin", now, rules); err != nil || series.Phase != HeroBanPhase {
|
||||
t.Fatalf("loser could not pick the next map: phase=%s err=%v", series.Phase, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testRoster() RosterDraft {
|
||||
teams := make([]Team, 2)
|
||||
for teamIndex, id := range []string{"a", "b"} {
|
||||
slots := []Slot{{PlayerID: id + "-t", Role: Tank, Rating: 20}}
|
||||
for i := 1; i <= 2; i++ {
|
||||
slots = append(slots,
|
||||
Slot{PlayerID: fmt.Sprintf("%s-d%d", id, i), Role: Damage, Rating: 20},
|
||||
Slot{PlayerID: fmt.Sprintf("%s-s%d", id, i), Role: Support, Rating: 20},
|
||||
)
|
||||
}
|
||||
teams[teamIndex] = Team{ID: id, EventID: "event", Name: id, Slots: slots}
|
||||
}
|
||||
return RosterDraft{EventID: "event", Teams: teams, Reserve: []string{"reserve"}}
|
||||
}
|
||||
|
||||
func testRules() Ruleset {
|
||||
return Ruleset{
|
||||
ID: "rules", BestOf: 3, HeroBansPerTeam: 2,
|
||||
MapPools: [][]string{{"Map 1", "Map 2", "Map 3"}, {"Map 1", "Map 4", "Map 5"}, {"Map 1", "Map 2", "Map 6"}},
|
||||
Heroes: []Hero{
|
||||
{Name: "Tank A", Role: Tank}, {Name: "Tank B", Role: Tank},
|
||||
{Name: "Damage A", Role: Damage}, {Name: "Damage B", Role: Damage},
|
||||
{Name: "Support A", Role: Support}, {Name: "Support B", Role: Support},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstAvailableMap(draft *MapDraft) string {
|
||||
for _, name := range draft.Pool {
|
||||
if !contains(draft.Banned, name) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contains(values []string, value string) bool {
|
||||
for _, item := range values {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user