From 23ba04beef8e8ee81a80edb12cbad3be982ca8af Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 02:44:30 +0300 Subject: [PATCH] Enhance roster management by implementing cross-role player swapping and updating player movement logic. Introduce rating checks for player roles during roster operations and ensure proper handling of captain assignments. Update frontend components to reflect changes in player movement capabilities. --- backend/internal/application/workflow.go | 49 ++++++++++++++++++++- backend/internal/domain/event_workflow.go | 52 ++++++++++++++++++++--- backend/internal/domain/workflow_test.go | 10 ++++- frontend/src/App.tsx | 8 ++-- 4 files changed, 107 insertions(+), 12 deletions(-) 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() {