feat(guild): leave + remove with leader-leaves rule

- LeaveAsync: regular/subleader removes self; leader blocked if members remain;
  sole-member leader auto-routes to BreakupAsync.
- RemoveAsync: leader-only kick (SubLeader has no remove authority per
  GuildManager.cs decompile); self-remove and cross-guild target rejected.
- GuildController: wired /guild/leave (BaseRequest) and /guild/remove
  (GuildRemoveRequest.remove_viewer_id field).
- SpyGuildChatService: extended Emissions with EmissionsWithBody tuple to
  allow body-content assertions in leave/remove tests.
- 8 new integration tests; all 1493 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 14:16:47 -04:00
parent 901c3d7568
commit 00fb28681b
4 changed files with 391 additions and 6 deletions

View File

@@ -362,11 +362,46 @@ public sealed class GuildService : IGuildService
return GuildOpResult.Ok;
}
public Task<GuildOpResult> LeaveAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public async Task<GuildOpResult> LeaveAsync(long viewerId, CancellationToken ct = default)
{
var m = await _members.GetMembershipAsync(viewerId, ct);
if (m is null) return new(GuildOpResultCode.NotInGuild);
public Task<GuildOpResult> RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
var memberCount = await _members.CountByGuildAsync(m.GuildId, ct);
if (m.Role == GuildRole.Leader)
{
// Sole-member leader: auto-breakup.
if (memberCount == 1) return await BreakupAsync(viewerId, ct);
// Leader with remaining members: blocked.
return new(GuildOpResultCode.LeaderLeaveBlocked);
}
// Regular or SubLeader: remove self.
await _members.RemoveAsync(m.GuildId, viewerId, ct);
await _viewers.ClearGuildIdAsync(viewerId, ct);
await _chat.EmitSystemEventAsync(m.GuildId, viewerId, GuildChatMessageType.Leave, body: null, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
{
var caller = await _members.GetMembershipAsync(callerViewerId, ct);
if (caller is null) return new(GuildOpResultCode.NotInGuild);
// Only Leader may kick (GuildManager.cs has no HasAuthorityRemove — client has no SubLeader kick UI).
if (caller.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
// Cannot kick yourself.
if (callerViewerId == targetViewerId) return new(GuildOpResultCode.PermissionDenied);
var target = await _members.GetMembershipAsync(targetViewerId, ct);
if (target is null || target.GuildId != caller.GuildId) return new(GuildOpResultCode.TargetNotInGuild);
await _members.RemoveAsync(caller.GuildId, targetViewerId, ct);
await _viewers.ClearGuildIdAsync(targetViewerId, ct);
await _chat.EmitSystemEventAsync(caller.GuildId, callerViewerId, GuildChatMessageType.Remove,
body: targetViewerId.ToString(), ct);
return GuildOpResult.Ok;
}
public Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
=> throw new NotImplementedException();