439 lines
12 KiB
Markdown
439 lines
12 KiB
Markdown
# 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**
|
||
|
||
```csharp
|
||
public sealed record WebParticipant(
|
||
Guid Id,
|
||
long TelegramId,
|
||
string DisplayName,
|
||
string? TelegramUsername,
|
||
string RsvpStatus,
|
||
string RegistrationStatus,
|
||
bool IsGm,
|
||
DateTime? RespondedAt);
|
||
```
|
||
|
||
**Step 2: Commit**
|
||
|
||
```bash
|
||
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:
|
||
```csharp
|
||
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
|
||
```
|
||
|
||
**Step 2: Implement in SessionService**
|
||
|
||
In `SessionService.cs`, add:
|
||
```csharp
|
||
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:
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
```csharp
|
||
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
|
||
```
|
||
|
||
**Step 2: Implement in SessionService**
|
||
|
||
In `SessionService.cs`, add:
|
||
```csharp
|
||
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:
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj -v n
|
||
```
|
||
|
||
**Step 4:** Commit
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
git tag v1.9.7
|
||
|
||
git push origin main --tags
|
||
```
|
||
|
||
**Step 3:** Create release via MCP gitea_create_release.
|
||
|
||
---
|