diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go index 20baa91..c9ed708 100644 --- a/backend/internal/application/workflow.go +++ b/backend/internal/application/workflow.go @@ -147,7 +147,24 @@ func (s *Service) SwapRoster(ctx context.Context, actor domain.Account, eventID, if roster.Version != expectedVersion { return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict) } - if err = roster.Swap(teamA, playerA, teamB, playerB); err != nil { + roleA, roleB := rosterPlayerRole(roster, teamA, playerA), rosterPlayerRole(roster, teamB, playerB) + players, err := s.Store.ListPlayers(ctx) + if err != nil { + return roster, err + } + ratingAForB, ratingBForA := 0, 0 + for _, player := range players { + if player.ID == playerA { + ratingAForB = ratingForRole(player, roleB) + } + if player.ID == playerB { + ratingBForA = ratingForRole(player, roleA) + } + } + if ratingAForB == 0 || ratingBForA == 0 { + return roster, domain.ErrNotFound + } + if err = roster.SwapAcrossRoles(teamA, playerA, teamB, playerB, ratingBForA, ratingAForB); err != nil { return roster, err } roster, err = s.Store.SaveRoster(ctx, roster, expectedVersion) @@ -170,7 +187,21 @@ func (s *Service) MoveRosterPlayer(ctx context.Context, actor domain.Account, ev if roster.Version != expectedVersion { return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict) } - if err = roster.MoveToEmpty(fromTeamID, playerID, toTeamID, role); err != nil { + players, err := s.Store.ListPlayers(ctx) + if err != nil { + return roster, err + } + targetRating := 0 + for _, player := range players { + if player.ID == playerID { + targetRating = ratingForRole(player, role) + break + } + } + if targetRating == 0 { + return roster, domain.ErrNotFound + } + if err = roster.MoveToEmpty(fromTeamID, playerID, toTeamID, role, targetRating); err != nil { return roster, err } return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_moved") @@ -621,3 +652,17 @@ func ratingForRole(player domain.Player, role domain.Role) int { return 0 } } + +func rosterPlayerRole(roster domain.RosterDraft, teamID, playerID string) domain.Role { + for _, team := range roster.Teams { + if team.ID != teamID { + continue + } + for _, slot := range team.Slots { + if slot.PlayerID == playerID { + return slot.Role + } + } + } + return "" +} diff --git a/backend/internal/domain/event_workflow.go b/backend/internal/domain/event_workflow.go index 97ae90b..2feb9e1 100644 --- a/backend/internal/domain/event_workflow.go +++ b/backend/internal/domain/event_workflow.go @@ -82,14 +82,17 @@ func (r *RosterDraft) Swap(teamAID, playerAID, teamBID, playerBID string) error return fmt.Errorf("%w: rosters are locked", ErrConflict) } var a, b *Slot + var teamA, teamB *Team 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 + teamA = &r.Teams[teamIndex] } if r.Teams[teamIndex].ID == teamBID && slot.PlayerID == playerBID { b = slot + teamB = &r.Teams[teamIndex] } } } @@ -101,10 +104,52 @@ func (r *RosterDraft) Swap(teamAID, playerAID, teamBID, playerBID string) error } a.PlayerID, b.PlayerID = b.PlayerID, a.PlayerID a.Rating, b.Rating = b.Rating, a.Rating + clearMovedCaptains(teamA, playerAID, teamB, playerBID) r.Version++ return r.Validate(false) } +func (r *RosterDraft) SwapAcrossRoles(teamAID, playerAID, teamBID, playerBID string, playerBRatingForA, playerARatingForB int) error { + if r.Confirmed { + return fmt.Errorf("%w: rosters are locked", ErrConflict) + } + var a, b *Slot + var teamA, teamB *Team + 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 + teamA = &r.Teams[teamIndex] + } + if r.Teams[teamIndex].ID == teamBID && slot.PlayerID == playerBID { + b = slot + teamB = &r.Teams[teamIndex] + } + } + } + if a == nil || b == nil { + return ErrNotFound + } + a.PlayerID, b.PlayerID = b.PlayerID, a.PlayerID + a.Rating, b.Rating = playerBRatingForA, playerARatingForB + clearMovedCaptains(teamA, playerAID, teamB, playerBID) + r.Version++ + return r.Validate(false) +} + +func clearMovedCaptains(teamA *Team, playerAID string, teamB *Team, playerBID string) { + if teamA == nil || teamB == nil || teamA.ID == teamB.ID { + return + } + if teamA.CaptainPlayerID == playerAID { + teamA.CaptainPlayerID = "" + } + if teamB.CaptainPlayerID == playerBID { + teamB.CaptainPlayerID = "" + } +} + func (r *RosterDraft) Substitute(teamID, outgoingID, reserveID string, rating int) error { if r.Confirmed { return fmt.Errorf("%w: rosters are locked", ErrConflict) @@ -158,7 +203,7 @@ func (r *RosterDraft) PlaceReserve(teamID string, role Role, reserveID string, r return fmt.Errorf("%w: empty role slot not found", ErrNotFound) } -func (r *RosterDraft) MoveToEmpty(fromTeamID, playerID, toTeamID string, role Role) error { +func (r *RosterDraft) MoveToEmpty(fromTeamID, playerID, toTeamID string, role Role, targetRating int) error { if r.Confirmed { return fmt.Errorf("%w: rosters are locked", ErrConflict) } @@ -179,10 +224,7 @@ func (r *RosterDraft) MoveToEmpty(fromTeamID, playerID, toTeamID string, role Ro if source == nil || target == nil { return ErrNotFound } - if source.Role != target.Role { - return fmt.Errorf("%w: only equal roles can be moved", ErrInvalid) - } - target.PlayerID, target.Rating = source.PlayerID, source.Rating + target.PlayerID, target.Rating = source.PlayerID, targetRating source.PlayerID, source.Rating = "", 0 if sourceTeam.CaptainPlayerID == playerID { sourceTeam.CaptainPlayerID = "" diff --git a/backend/internal/domain/workflow_test.go b/backend/internal/domain/workflow_test.go index 5688d88..6e80b01 100644 --- a/backend/internal/domain/workflow_test.go +++ b/backend/internal/domain/workflow_test.go @@ -67,12 +67,20 @@ func TestRosterDragOperationsPreserveEmptyRoleSlots(t *testing.T) { if err := roster.MoveToReserve("b", "b-t"); err != nil { t.Fatal(err) } - if err := roster.MoveToEmpty("a", "reserve", "b", Tank); err != nil { + if err := roster.MoveToEmpty("a", "reserve", "b", Tank, 31); err != nil { t.Fatal(err) } if roster.Teams[1].Slots[0].PlayerID != "reserve" { t.Fatal("dragging into an empty role slot did not move the player") } + roster.Teams[0].CaptainPlayerID = "a-d1" + roster.Teams[1].CaptainPlayerID = "reserve" + if err := roster.SwapAcrossRoles("a", "a-d1", "b", "reserve", 25, 33); err != nil { + t.Fatalf("cross-role drag swap failed: %v", err) + } + if roster.Teams[0].CaptainPlayerID != "" || roster.Teams[1].CaptainPlayerID != "" { + t.Fatal("captains that moved to another team were not cleared") + } } func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e35d68..7f23b0e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -496,7 +496,7 @@ function WorkflowControl({ eventId }: { eventId: string }) { {state === 'RegistrationOpen' && close.mutate()} />} {state === 'RegistrationClosed' && generate.mutate()} />} {state === 'Balancing' && candidates.length === 0 && generate.mutate()} />} - {state === 'Balancing' && candidates.length > 0 &&
{candidates[selectedCandidate].teams.map((team) => )}

Selecting creates a versioned roster draft.

} + {state === 'Balancing' && candidates.length > 0 &&
{candidates[selectedCandidate].teams.map((team) => )}

Selecting creates a versioned roster draft.

} {roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && } {state === 'RostersDraft' && roster.data && confirm.mutate()} />} {state === 'RostersConfirmed' && start.mutate()} />} @@ -563,7 +563,7 @@ function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { } } return
-

Roster editor

Version {roster.version} · drag players between equal-role slots or through reserve.

{roster.confirmed && Locked}
+

Roster editor

Version {roster.version} · drag players into any role slot or through reserve.

{roster.confirmed && Locked}
{roster.teams.map((team) =>

{team.name}

{team.slots.map((slot, slotIndex) =>
{ if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}>{slot.role[0]}{slot.playerId ? <> startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role })}>{names.get(slot.playerId) ?? slot.playerId}{rankLabel(slot.rating, language)}{!locked && } : <>Empty{slot.role}}
)}
)}
@@ -588,7 +588,7 @@ function LegacyBalanceView({ eventId }: { eventId: string }) { return
Imbalance score{candidate.score}
Preference fit{candidate.explanations.map((explanation) => {explanation})}

Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.

{candidate.teams.map((team) => )}
Reserve · {candidate.reserve.length}Not assigned to a complete 5v5 roster
{candidate.reserve.map((p) => {p.displayName})}

{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}

} -function TeamCard({ team, eventId }: { team: Team; eventId: string }) { +function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team; eventId: string; allowCaptainAssignment?: boolean }) { const client = useQueryClient() const language = useLanguage() const [captain, setCaptain] = useState(team.captainId ?? '') @@ -597,7 +597,7 @@ function TeamCard({ team, eventId }: { team: Team; eventId: string }) { mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId), onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }), }) - return
Team {team.side === 'alpha' ? 'A' : 'B'}

{team.name}

{rankLabel(team.averageRating, language)} avg
{team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}{player.displayName}{rankLabel(player.ratings[assignedRole], language)}
)}
+ return
Team {team.side === 'alpha' ? 'A' : 'B'}

{team.name}

{rankLabel(team.averageRating, language)} avg
{team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}{player.displayName}{rankLabel(player.ratings[assignedRole], language)}
)}
{allowCaptainAssignment && }
} function LiveSeriesPage() {