feat(guild): change_role atomic transfer + friend_list

- ChangeRoleAsync: Leader-only role management with SubLeader cap check.
  Atomic leader transfer loads both member rows + Guild.LeaderViewerId in
  one _db.SaveChangesAsync call (no split saves). Emits ChangeLeader (6)
  or ChangeSubLeader (7) chat events. Same-role is no-op / returns Ok.
- /guild/change_role controller: calls ChangeRoleAsync, returns full updated
  member list (GuildChangeRoleResponse.Members[]).
- /guild/friend_list controller: calls IFriendService.GetFriendsAsync, then
  annotates each friend with is_join_guild from Viewer.GuildId column.
- IGuildRepository.UpdateLeaderViewerIdAsync: added for completeness even
  though the atomic transfer path bypasses it via direct EF mutation.
- Tests: 8 GuildServiceChangeRoleTests (role changes, cap, atomic transfer,
  no-op, permission); 3 GuildChangeRoleFriendListFlowTests (HTTP roundtrip,
  is_join_guild); 2 GuildWireShape tests (literal JSON shape); 1508 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 14:27:13 -04:00
parent 00fb28681b
commit f2b996a593
7 changed files with 833 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Entities.Guild;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Guild;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
@@ -20,15 +21,17 @@ public sealed class GuildController : SVSimController
private readonly IGuildService _guild;
private readonly IGameConfigService _configs;
private readonly SVSimDbContext _db;
private readonly IFriendService _friends;
// Wire error code returned when guild operations fail (non-1 result_code).
private const int GuildErrorResultCode = 2;
public GuildController(IGuildService guild, IGameConfigService configs, SVSimDbContext db)
public GuildController(IGuildService guild, IGameConfigService configs, SVSimDbContext db, IFriendService friends)
{
_guild = guild;
_configs = configs;
_db = db;
_friends = friends;
}
[HttpPost("info")]
@@ -171,8 +174,40 @@ public sealed class GuildController : SVSimController
}
[HttpPost("friend_list")]
public Task<ActionResult<GuildFriendListResponse>> FriendList([FromBody] BaseRequest _, CancellationToken ct)
=> Task.FromResult<ActionResult<GuildFriendListResponse>>(new GuildFriendListResponse { Friends = new() });
public async Task<ActionResult<GuildFriendListResponse>> FriendList([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var friendInfo = await _friends.GetFriendsAsync(viewerId, ct);
if (friendInfo.Friends.Count == 0)
return new GuildFriendListResponse { Friends = new() };
// For each friend, determine if they are already in a guild (is_join_guild).
var friendViewerIds = friendInfo.Friends.Select(f => (long)f.ViewerId).ToList();
var viewerRows = await _db.Viewers
.AsNoTracking()
.Where(v => friendViewerIds.Contains(v.Id))
.Select(v => new { v.Id, v.GuildId })
.ToDictionaryAsync(v => v.Id, v => v.GuildId, ct);
var candidates = new List<GuildInviteCandidateDto>(friendInfo.Friends.Count);
foreach (var f in friendInfo.Friends)
{
viewerRows.TryGetValue(f.ViewerId, out var guildId);
candidates.Add(new GuildInviteCandidateDto
{
ViewerId = f.ViewerId,
Name = f.Name,
EmblemId = f.EmblemId,
CountryCode = f.CountryCode,
Rank = f.Rank,
DegreeId = f.DegreeId,
IsJoinGuild = guildId is not null,
});
}
return new GuildFriendListResponse { Friends = candidates };
}
[HttpPost("invite_user_list")]
public async Task<ActionResult<GuildInviteUserListResponse>> InviteUserList([FromBody] BaseRequest _, CancellationToken ct)
@@ -315,8 +350,23 @@ public sealed class GuildController : SVSimController
}
[HttpPost("change_role")]
public Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
=> Task.FromResult<ActionResult<GuildChangeRoleResponse>>(new GuildChangeRoleResponse { Members = new() });
public async Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var r = await _guild.ChangeRoleAsync(viewerId, req.TargetViewerId, req.RoleId, ct);
if (!r.IsOk) return WireError();
// Load caller's membership to get the guild id, then return the full updated member list.
var membership = await _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
if (membership is null) return WireError();
var members = await _db.GuildMembers
.Where(m => m.GuildId == membership.GuildId)
.ToListAsync(ct);
var memberDtos = await ToMemberDtoListAsync(members, viewerId, ct);
return new GuildChangeRoleResponse { Members = memberDtos };
}
// ===== Private helpers =====