Enhance Discord role management by adding hoist functionality
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
This commit is contained in:
2026-07-19 11:52:57 +03:00
parent 966c81ba12
commit e5646ba33d
9 changed files with 139 additions and 23 deletions

View File

@@ -46,7 +46,8 @@ type RoleManager struct {
} }
type discordRoleResponse struct { type discordRoleResponse struct {
ID string `json:"id"` ID string `json:"id"`
Position int `json:"position"`
} }
type discordHTTPError struct { type discordHTTPError struct {
@@ -181,10 +182,14 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
registeredRole := existingRole(roles, application.DiscordRoleKindRegistered)
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles) roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = w.ensureTeamsAboveRegistered(ctx, roles, registeredRole); err != nil {
return nil, err
}
desired, warnings, err := w.desiredAssignments(ctx, current, roles) desired, warnings, err := w.desiredAssignments(ctx, current, roles)
if err != nil { if err != nil {
return warnings, err return warnings, err
@@ -234,7 +239,7 @@ func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]strin
} }
names := rsvpRoleNames(w.locale, state.Event.Name) names := rsvpRoleNames(w.locale, state.Event.Name)
desiredRoles := []application.DiscordManagedRole{ desiredRoles := []application.DiscordManagedRole{
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered]}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered], Hoist: true},
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindGoing, RoleName: names[application.DiscordRoleKindGoing]}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindGoing, RoleName: names[application.DiscordRoleKindGoing]},
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindMaybe, RoleName: names[application.DiscordRoleKindMaybe]}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindMaybe, RoleName: names[application.DiscordRoleKindMaybe]},
{Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindNotGoing, RoleName: names[application.DiscordRoleKindNotGoing]}, {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindNotGoing, RoleName: names[application.DiscordRoleKindNotGoing]},
@@ -281,7 +286,7 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste
for _, team := range roster.Teams { for _, team := range roster.Teams {
teamName := truncate(strings.TrimSpace(team.Name), 100) teamName := truncate(strings.TrimSpace(team.Name), 100)
desired = append(desired, desired = append(desired,
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName}, application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName, Hoist: true},
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)}, application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
) )
} }
@@ -302,8 +307,8 @@ func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []applic
desiredKeys[key] = true desiredKeys[key] = true
if saved, ok := existingByKey[key]; ok { if saved, ok := existingByKey[key]; ok {
role.DiscordRoleID = saved.DiscordRoleID role.DiscordRoleID = saved.DiscordRoleID
if saved.RoleName != role.RoleName { if saved.RoleName != role.RoleName || saved.Hoist != role.Hoist {
if err := w.manager.RenameRole(ctx, saved.DiscordRoleID, role.RoleName); err != nil { if err := w.manager.UpdateRole(ctx, saved.DiscordRoleID, role.RoleName, role.Hoist); err != nil {
return nil, err return nil, err
} }
if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil { if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
@@ -311,7 +316,7 @@ func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []applic
} }
} }
} else { } else {
roleID, err := w.manager.CreateRole(ctx, role.RoleName) roleID, err := w.manager.CreateRole(ctx, role.RoleName, role.Hoist)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -486,10 +491,26 @@ func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.Dis
return w.store.DeleteDiscordManagedRole(ctx, role) return w.store.DeleteDiscordManagedRole(ctx, role)
} }
func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) { func (w *RoleWorker) ensureTeamsAboveRegistered(ctx context.Context, roles []application.DiscordManagedRole, registered application.DiscordManagedRole) error {
if registered.DiscordRoleID == "" {
return nil
}
teamRoleIDs := make([]string, 0)
for _, role := range roles {
if role.Kind == application.DiscordRoleKindTeam {
teamRoleIDs = append(teamRoleIDs, role.DiscordRoleID)
}
}
if len(teamRoleIDs) == 0 {
return nil
}
return w.manager.MoveRolesAbove(ctx, teamRoleIDs, registered.DiscordRoleID)
}
func (m *RoleManager) CreateRole(ctx context.Context, name string, hoist bool) (string, error) {
var response discordRoleResponse var response discordRoleResponse
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{ if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
"name": name, "permissions": "0", "hoist": false, "mentionable": false, "name": name, "permissions": "0", "hoist": hoist, "mentionable": false,
}, &response); err != nil { }, &response); err != nil {
return "", err return "", err
} }
@@ -499,8 +520,38 @@ func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, erro
return response.ID, nil return response.ID, nil
} }
func (m *RoleManager) RenameRole(ctx context.Context, roleID, name string) error { func (m *RoleManager) UpdateRole(ctx context.Context, roleID, name string, hoist bool) error {
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]string{"name": name}, nil) return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]any{"name": name, "hoist": hoist}, nil)
}
func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error {
var roles []discordRoleResponse
if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil {
return err
}
positions := make(map[string]int, len(roles))
for _, role := range roles {
positions[role.ID] = role.Position
}
anchorPosition, ok := positions[anchorRoleID]
if !ok {
return nil
}
needsMove := false
for _, roleID := range roleIDs {
if position, found := positions[roleID]; !found || position <= anchorPosition {
needsMove = true
break
}
}
if !needsMove {
return nil
}
payload := make([]map[string]any, 0, len(roleIDs))
for index, roleID := range roleIDs {
payload = append(payload, map[string]any{"id": roleID, "position": anchorPosition + index + 1})
}
return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil)
} }
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error { func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
@@ -575,6 +626,15 @@ func managedRoleKey(role application.DiscordManagedRole) string {
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00") return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
} }
func existingRole(roles []application.DiscordManagedRole, kind string) application.DiscordManagedRole {
for _, role := range roles {
if role.Kind == kind {
return role
}
}
return application.DiscordManagedRole{}
}
func roleKind(role domain.Role) string { func roleKind(role domain.Role) string {
switch role { switch role {
case domain.Tank: case domain.Tank:

View File

@@ -125,6 +125,8 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
var mu sync.Mutex var mu sync.Mutex
roleNames := make(map[string]string) roleNames := make(map[string]string)
roleHoists := make(map[string]bool)
rolePositions := map[string]int{"role-rsvp": 1}
methods := make([]string, 0) methods := make([]string, 0)
nextID := 0 nextID := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
@@ -137,15 +139,38 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
nextID++ nextID++
id := "role-" + string(rune('0'+nextID)) id := "role-" + string(rune('0'+nextID))
roleNames[id], _ = payload["name"].(string) roleNames[id], _ = payload["name"].(string)
roleHoists[id], _ = payload["hoist"].(bool)
rolePositions[id] = 0
response.Header().Set("Content-Type", "application/json") response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(map[string]string{"id": id}) _ = json.NewEncoder(response).Encode(map[string]string{"id": id})
return return
} }
if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") {
roles := []map[string]any{{"id": "role-rsvp", "position": rolePositions["role-rsvp"]}}
for id := range roleNames {
roles = append(roles, map[string]any{"id": id, "position": rolePositions[id]})
}
response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(roles)
return
}
if request.Method == http.MethodPatch { if request.Method == http.MethodPatch {
var payload map[string]string
_ = json.NewDecoder(request.Body).Decode(&payload)
parts := strings.Split(request.URL.Path, "/") parts := strings.Split(request.URL.Path, "/")
roleNames[parts[len(parts)-1]] = payload["name"] roleID := parts[len(parts)-1]
if roleID == "roles" {
var payload []struct {
ID string `json:"id"`
Position int `json:"position"`
}
_ = json.NewDecoder(request.Body).Decode(&payload)
for _, item := range payload {
rolePositions[item.ID] = item.Position
}
} else {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
roleNames[roleID], _ = payload["name"].(string)
}
} }
response.WriteHeader(http.StatusNoContent) response.WriteHeader(http.StatusNoContent)
})) }))
@@ -157,7 +182,7 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
store.active = []application.DiscordRoleRoster{item} store.active = []application.DiscordRoleRoster{item}
rsvpRole := application.DiscordManagedRole{ rsvpRole := application.DiscordManagedRole{
Scope: application.DiscordRoleScopeEvent, EventID: "event-1", Scope: application.DiscordRoleScopeEvent, EventID: "event-1",
Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", Hoist: true,
} }
store.roles[managedRoleKey(rsvpRole)] = rsvpRole store.roles[managedRoleKey(rsvpRole)] = rsvpRole
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}} store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
@@ -179,6 +204,14 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
if strings.Join(names, ",") != strings.Join(expectedNames, ",") { if strings.Join(names, ",") != strings.Join(expectedNames, ",") {
t.Fatalf("unexpected role names: %v", names) t.Fatalf("unexpected role names: %v", names)
} }
for id, name := range roleNames {
if roleHoists[id] != (name == "Alpha") {
t.Fatalf("unexpected hoist for %s: %v", name, roleHoists[id])
}
if name == "Alpha" && rolePositions[id] <= rolePositions["role-rsvp"] {
t.Fatalf("team role was not moved above registered role: team=%d registered=%d", rolePositions[id], rolePositions["role-rsvp"])
}
}
if got := assignmentCount(store.assignments); got != 11 { if got := assignmentCount(store.assignments); got != 11 {
t.Fatalf("expected 11 managed assignments, got %d", got) t.Fatalf("expected 11 managed assignments, got %d", got)
} }
@@ -191,8 +224,10 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
if _, err = worker.ProcessNext(context.Background()); err != nil { if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(methods) != firstRequestCount { for _, method := range methods[firstRequestCount:] {
t.Fatalf("idempotent reconcile made %d extra Discord calls", len(methods)-firstRequestCount) if !strings.HasPrefix(method, http.MethodGet+" ") {
t.Fatalf("idempotent reconcile made a mutating Discord call: %s", method)
}
} }
renamed := testDiscordRoster("Night Owls") renamed := testDiscordRoster("Night Owls")
@@ -236,6 +271,13 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) {
patches := 0 patches := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost { if request.Method == http.MethodPost {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
name, _ := payload["name"].(string)
hoist, _ := payload["hoist"].(bool)
if hoist != strings.HasPrefix(name, "Зарегистрирован:") {
t.Errorf("unexpected RSVP role hoist: name=%q hoist=%v", name, hoist)
}
nextID++ nextID++
_ = json.NewEncoder(response).Encode(map[string]string{"id": "rsvp-role-" + string(rune('0'+nextID))}) _ = json.NewEncoder(response).Encode(map[string]string{"id": "rsvp-role-" + string(rune('0'+nextID))})
return return

View File

@@ -78,7 +78,7 @@ func (s *Store) RetryDiscordRoleSyncJob(ctx context.Context, jobID int64, messag
} }
func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]application.DiscordManagedRole, error) { func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]application.DiscordManagedRole, error) {
rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name,hoist
FROM discord_managed_roles FROM discord_managed_roles
WHERE scope='global' OR event_id=$1 WHERE scope='global' OR event_id=$1
ORDER BY scope,event_id,team_id,kind`, eventID) ORDER BY scope,event_id,team_id,kind`, eventID)
@@ -89,7 +89,7 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]
roles := make([]application.DiscordManagedRole, 0) roles := make([]application.DiscordManagedRole, 0)
for rows.Next() { for rows.Next() {
var role application.DiscordManagedRole var role application.DiscordManagedRole
if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName); err != nil { if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName, &role.Hoist); err != nil {
return nil, err return nil, err
} }
roles = append(roles, role) roles = append(roles, role)
@@ -98,11 +98,11 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]
} }
func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error { func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name) _, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist)
VALUES($1,$2,$3,$4,$5,$6) VALUES($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(scope,event_id,team_id,kind) DO UPDATE ON CONFLICT(scope,event_id,team_id,kind) DO UPDATE
SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,updated_at=now()`, SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,hoist=excluded.hoist,updated_at=now()`,
role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName) role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName, role.Hoist)
return err return err
} }

View File

@@ -33,6 +33,7 @@ type DiscordManagedRole struct {
Kind string Kind string
DiscordRoleID string DiscordRoleID string
RoleName string RoleName string
Hoist bool
} }
type DiscordRoleSyncJob struct { type DiscordRoleSyncJob struct {

View File

@@ -0,0 +1,7 @@
-- +mixmaker Up
ALTER TABLE discord_managed_roles
ADD COLUMN hoist boolean NOT NULL DEFAULT false;
-- +mixmaker Down
ALTER TABLE discord_managed_roles
DROP COLUMN IF EXISTS hoist;

View File

@@ -9,6 +9,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена. Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс. Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles. При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles.
В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями.
## Подтверждённые требования ## Подтверждённые требования
@@ -83,6 +84,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
## Ближайший следующий шаг ## Ближайший следующий шаг
Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql` и `012_discord_rsvp_roles.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, анонс, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом. Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql`, `012_discord_rsvp_roles.sql` и `013_discord_role_hoist.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3. Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.

View File

@@ -124,6 +124,8 @@ Application-слой управляет единым versioned workflow собы
Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли. Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли.
Discord hoist включён только для team roles и общей `registered`-роли. Team roles автоматически поднимаются выше `registered`, поэтому после формирования состава игрок отображается в группе команды, а ещё не распределённые участники — в общей группе регистрации. Captain, Tank/Damage/Support и конкретные RSVP status roles остаются служебными и не создают отдельные группы участников.
### Капитаны ### Капитаны
Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит. Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит.

View File

@@ -32,6 +32,8 @@ Team/captain roles принадлежат конкретному Event и уда
Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event. Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event.
В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку.
### Жизненный цикл события ### Жизненный цикл события
Событие проходит серверно контролируемые состояния: Событие проходит серверно контролируемые состояния:

View File

@@ -83,6 +83,7 @@
- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing; - [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing;
- обновление анонса, напоминания, публикация составов и результатов; - обновление анонса, напоминания, публикация составов и результатов;
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей; - [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
- [x] отдельные member-list группы для команд и зарегистрированных участников;
- создание и очистка временных командных голосовых каналов; - создание и очистка временных командных голосовых каналов;
- импорт данных и резервное копирование. - импорт данных и резервное копирование.