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.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 02:44:30 +03:00
parent f468f9df82
commit 23ba04beef
4 changed files with 107 additions and 12 deletions

View File

@@ -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 ""
}

View File

@@ -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 = ""

View File

@@ -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) {

View File

@@ -496,7 +496,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} allowCaptainAssignment={false} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
{roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
@@ -563,7 +563,7 @@ function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: {
}
}
return <section className="card roster-editor">
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players between equal-role slots or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players into any role slot or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
<div className="roster-editor-body"><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role })}>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
</div>
@@ -588,7 +588,7 @@ function LegacyBalanceView({ eventId }: { eventId: string }) {
return <div className="balance-layout"><aside className="candidate-list"><div className="section-title"><div><h2>Server candidates</h2><p>Lower imbalance score is better</p></div><button className="icon-button" aria-label="Generate again" disabled={generate.isPending} onClick={() => generate.mutate()}><RefreshCw /></button></div>{candidates.map((item, i) => <button key={item.id} className={`candidate-card ${selected === item.id ? 'active' : ''}`} onClick={() => setSelected(item.id)}><span className="candidate-rank">0{i + 1}</span><span><strong>Score {item.score}</strong><small>Computed by backend balancer</small></span><ChevronRight /></button>)}</aside><section className="balance-main"><div className="balance-score card"><div><span className="eyebrow">Imbalance score</span><strong>{candidate.score}</strong></div><div className="balance-explanations"><span>Preference fit</span>{candidate.explanations.map((explanation) => <small key={explanation}><Check />{explanation}</small>)}</div><p>Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.</p></div><div className="teams-grid">{candidate.teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="reserve-bar card"><div><UsersRound /><span><strong>Reserve · {candidate.reserve.length}</strong><small>Not assigned to a complete 5v5 roster</small></span></div><div>{candidate.reserve.map((p) => <Badge key={p.id}>{p.displayName}</Badge>)}</div></div><div className="sticky-action"><p>{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}><Check />{select.isPending ? 'Saving…' : 'Use this balance'}</button></div></section></div>
}
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 <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div><label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label></article>
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
}
function LiveSeriesPage() {