12 KiB
Player List + Kick + Waitlist Promotion Implementation Plan
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Goal: Add a player list (with names) to the Web UI session views, allow GM to kick a specific player, and auto-promote the next waitlisted player.
Architecture: Extend ISessionStore with participant queries and a remove method. Update GroupDetails.razor to show expandable participant lists. Reuse existing PromoteWaitlistedPlayerAsync logic after removal.
Tech Stack: C# 14, Blazor SSR, Dapper, PostgreSQL
Task 1: Add domain model for WebParticipant
Objective: Create a DTO to represent a session participant in the web layer.
Files:
- Modify:
src/GmRelay.Web/Services/SessionService.cs
Step 1: Add record
public sealed record WebParticipant(
Guid Id,
long TelegramId,
string DisplayName,
string? TelegramUsername,
string RsvpStatus,
string RegistrationStatus,
bool IsGm,
DateTime? RespondedAt);
Step 2: Commit
git add src/GmRelay.Web/Services/SessionService.cs
git commit -m "feat: add WebParticipant record"
Task 2: Add GetSessionParticipantsAsync to ISessionStore
Objective: Retrieve all participants for a session with full player info.
Files:
- Modify:
src/GmRelay.Web/Services/ISessionStore.cs - Modify:
src/GmRelay.Web/Services/SessionService.cs - Modify:
src/GmRelay.Web/Services/AuthorizedSessionService.cs
Step 1: Add to interface
In ISessionStore.cs, add:
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Step 2: Implement in SessionService
In SessionService.cs, add:
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
ORDER BY sp.is_gm DESC,
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
sp.created_at
""",
new { SessionId = sessionId })).ToList();
}
Step 3: Add authorized wrapper
In AuthorizedSessionService.cs, add:
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId);
}
Step 4: Commit
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add GetSessionParticipantsAsync"
Task 3: Add RemovePlayerFromSessionAsync with waitlist promotion
Objective: Allow GM to remove a specific player; auto-promote next waitlisted player if conditions met.
Files:
- Modify:
src/GmRelay.Web/Services/ISessionStore.cs - Modify:
src/GmRelay.Web/Services/SessionService.cs - Modify:
src/GmRelay.Web/Services/AuthorizedSessionService.cs
Step 1: Add to interface
In ISessionStore.cs, add:
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Step 2: Implement in SessionService
In SessionService.cs, add:
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.max_players AS MaxPlayers,
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
FOR UPDATE",
new { SessionId = sessionId, GroupId = groupId },
transaction);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, 0);
}
// Verify participant exists in this session
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
"""
SELECT sp.id AS Id,
p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm,
sp.responded_at AS RespondedAt
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
""",
new { ParticipantId = participantId, SessionId = sessionId },
transaction);
if (participant is null)
{
throw new InvalidOperationException("Участник не найден в этой сессии.");
}
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
await conn.ExecuteAsync(
"DELETE FROM session_participants WHERE id = @ParticipantId",
new { ParticipantId = participantId },
transaction);
WebPromotedParticipantDto? promoted = null;
if (wasActive)
{
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
"""
SELECT sp.id AS ParticipantRowId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
ORDER BY sp.created_at ASC, sp.id ASC
LIMIT 1
FOR UPDATE OF sp
""",
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
transaction);
if (promoted is not null)
{
await conn.ExecuteAsync(
"""
UPDATE session_participants
SET registration_status = @Active,
rsvp_status = @Pending,
responded_at = NULL
WHERE id = @ParticipantRowId
""",
new
{
promoted.ParticipantRowId,
Active = ParticipantRegistrationStatus.Active,
Pending = RsvpStatus.Pending
},
transaction);
}
}
await transaction.CommitAsync();
// Notifications
await bot.SendMessage(
session.TelegramChatId,
$"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
if (promoted is not null)
{
await bot.SendMessage(
session.TelegramChatId,
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
}
Step 3: Add authorized wrapper
In AuthorizedSessionService.cs, add:
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
{
var session = await GetSessionForGmAsync(sessionId, gmId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, gmId);
}
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
}
Step 4: Commit
git add src/GmRelay.Web/Services/ISessionStore.cs
git add src/GmRelay.Web/Services/SessionService.cs
git add src/GmRelay.Web/Services/AuthorizedSessionService.cs
git commit -m "feat: add RemovePlayerFromSessionAsync with waitlist promotion"
Task 4: Modify GroupDetails.razor to show participant list
Objective: Add expandable player lists to each session row with kick buttons.
Files:
- Modify:
src/GmRelay.Web/Components/Pages/GroupDetails.razor
Step 1: Add participants dictionary and kickingParticipantId state variables.
Step 2: Add LoadParticipants(Guid sessionId) and KickParticipant(Guid sessionId, Guid participantId) methods.
Step 3: In desktop table, add a new column or expand row with participant list.
Step 4: In mobile cards, add expandable participant section.
Step 5: Add styles to app.css if needed (badge styles are already present).
Step 6: Commit
git add src/GmRelay.Web/Components/Pages/GroupDetails.razor
git add src/GmRelay.Web/wwwroot/app.css
git commit -m "feat: show player list and kick button in GroupDetails"
Task 5: Modify EditSession.razor to show participant list
Objective: Show participant list on the edit page with kick capability.
Files:
- Modify:
src/GmRelay.Web/Components/Pages/EditSession.razor
Step 1: Load participants in OnInitializedAsync.
Step 2: Render participant list below the edit form.
Step 3: Add kick button for each non-GM participant.
Step 4: Commit
git add src/GmRelay.Web/Components/Pages/EditSession.razor
git commit -m "feat: show player list and kick button in EditSession"
Task 6: Add backend tests
Objective: Cover new GetSessionParticipants and RemovePlayerFromSession logic.
Files:
- Create:
tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs
Step 1: Write tests for GetSessionParticipantsForGmAsync.
Step 2: Write tests for RemovePlayerFromSessionForGmAsync including waitlist promotion.
Step 3: Run tests
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n
Step 4: Commit
git add tests/GmRelay.Bot.Tests/Web/SessionParticipantTests.cs
git commit -m "test: add SessionParticipant service tests"
Task 7: Update README
Objective: Bump version and document new features.
Files:
- Modify:
README.md
Step 1: Change version from v1.9.6 to v1.9.7.
Step 2: Add bullet under Web Dashboard: player list with kick and auto-promote.
Step 3: Commit
git add README.md
git commit -m "docs: bump README to v1.9.7, document player list kick"
Task 8: Update Wiki
Objective: Update Руководство ГМа page with player management instructions.
Files:
- Modify: Wiki page
Руководство ГМа
Step 1: Read current wiki content via MCP.
Step 2: Add section about viewing player list and removing players.
Step 3: Update via MCP.
Task 9: Push branch and run CI
Objective: Push branch, monitor workflow, fix issues.
Step 1: Push
git push -u origin feat/player-list-kick-waitlist
Step 2: Check workflow run via MCP gitea actions.
Step 3: Fix any issues.
Task 10: Merge and create release
Objective: Merge PR (or fast-forward), tag, create release.
Step 1: Merge to main
git checkout main
git merge --no-ff feat/player-list-kick-waitlist -m "feat: player list, kick, and waitlist promotion (#X)"
Step 2: Tag v1.9.7
git tag v1.9.7
git push origin main --tags
Step 3: Create release via MCP gitea_create_release.