Files
SVSimServer/SVSim.Database/Services/Guild/GuildService.cs
gamer147 d7f9ba8f2a feat(guild): join / cancel_join_request / join_request_list
Implements three join-path state machine endpoints:
- /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply),
  ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels
  join requests; MaxMemberNum cap enforced.
- /guild/cancel_join_request: cancels all pending requests for caller (no
  request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept).
- /guild/join_request_list: leader/subleader sees pending applicants with
  viewer profile (name/emblem/degree/request_time unix secs).

IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId)
to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched
record added to GuildServiceTypes. Re-apply after cancel resets existing row
in-place to avoid composite-PK conflict. 11 integration tests, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:44:04 -04:00

438 lines
20 KiB
C#

using Microsoft.EntityFrameworkCore;
using SVSim.Database.Entities.Guild;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Guild;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
namespace SVSim.Database.Services.Guild;
public sealed class GuildService : IGuildService
{
private readonly IGuildRepository _guilds;
private readonly IGuildMemberRepository _members;
private readonly IGuildInviteRepository _invites;
private readonly IGuildJoinRequestRepository _joinRequests;
private readonly IGuildChatMessageRepository _chatMessages;
private readonly IGameConfigService _config;
private readonly IGuildIdGenerator _idGen;
private readonly IGuildChatService _chat;
private readonly IViewerRepository _viewers;
private readonly SVSimDbContext _db;
public GuildService(
IGuildRepository guilds,
IGuildMemberRepository members,
IGuildInviteRepository invites,
IGuildJoinRequestRepository joinRequests,
IGuildChatMessageRepository chatMessages,
IGameConfigService config,
IGuildIdGenerator idGen,
IGuildChatService chat,
IViewerRepository viewers,
SVSimDbContext db)
{
_guilds = guilds;
_members = members;
_invites = invites;
_joinRequests = joinRequests;
_chatMessages = chatMessages;
_config = config;
_idGen = idGen;
_chat = chat;
_viewers = viewers;
_db = db;
}
public async Task<GuildFullView?> GetMyGuildAsync(long viewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(viewerId, ct);
if (membership is null) return null;
var guild = await _guilds.GetWithMembersAsync(membership.GuildId, ct);
if (guild is null || guild.BreakupAt is not null) return null;
int joinReqCount = await _joinRequests.CountPendingForGuildAsync(guild.GuildId, ct);
int inviteCount = await _invites.CountPendingForInviteeAsync(viewerId, ct);
return new GuildFullView(guild, guild.Members, joinReqCount, inviteCount);
}
public Task<Entities.Guild.Guild?> GetActiveAsync(int guildId, CancellationToken ct = default)
=> _guilds.GetActiveByIdAsync(guildId, ct);
public async Task<IReadOnlyList<GuildSearchEntry>> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default)
{
var cfg = _config.Get<GuildConfig>();
var rows = await _guilds.SearchAsync(name ?? "", activity, joinCondition, memberBucket, cfg.MaxMemberNum, cfg.SearchResultCap, ct);
var guildIds = rows.Select(r => r.GuildId).ToList();
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
var leaderIds = rows.Select(r => r.LeaderViewerId).Distinct().ToList();
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
return rows.Select(r => new GuildSearchEntry(r, memberCounts.GetValueOrDefault(r.GuildId, 0), leaderNames.GetValueOrDefault(r.LeaderViewerId, ""))).ToList();
}
public async Task<GuildOpResult> CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64)
return new(GuildOpResultCode.NameInvalid);
if (req.Activity is < 1 or > 16)
return new(GuildOpResultCode.NameInvalid);
if (req.JoinCondition is < 1 or > 3)
return new(GuildOpResultCode.NameInvalid);
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
return new(GuildOpResultCode.AlreadyInGuild);
if (await _guilds.NameExistsAsync(req.Name, ct))
return new(GuildOpResultCode.NameTaken);
var viewer = await _db.Viewers
.Include(v => v.Info.SelectedEmblem)
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
if (viewer is null) return new(GuildOpResultCode.PermissionDenied);
var id = await _idGen.NextAsync(
(cand, c) => _guilds.GetByIdAsync(cand, c).ContinueWith(t => t.Result is not null, c),
ct);
var now = DateTime.UtcNow;
var g = new Entities.Guild.Guild
{
GuildId = id,
Name = req.Name,
Description = "",
LeaderViewerId = viewerId,
EmblemId = DefaultEmblemId(viewer),
Activity = (GuildActivity)req.Activity,
JoinCondition = (GuildJoinCondition)req.JoinCondition,
CreatedAt = now,
};
await _guilds.AddAsync(g, ct);
await _members.AddAsync(
new GuildMember { GuildId = id, ViewerId = viewerId, Role = GuildRole.Leader, JoinedAt = now },
ct);
// Side-effects: clear any pending invites + cancel pending join requests for this viewer.
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
// Update Viewer.GuildId pointer for quick lookups.
viewer.GuildId = id;
await _db.SaveChangesAsync(ct);
await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct);
return new(GuildOpResultCode.Ok, GuildId: id);
}
/// <summary>
/// Returns the viewer's currently-equipped emblem id, or 100000000 (default) if none.
/// </summary>
private static long DefaultEmblemId(Models.Viewer viewer)
{
var emblemId = viewer.Info?.SelectedEmblem?.Id;
return emblemId is > 0 ? emblemId.Value : 100_000_000L;
}
public async Task<GuildOpResult> UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default)
{
var m = await _members.GetMembershipAsync(viewerId, ct);
if (m is null) return new(GuildOpResultCode.NotInGuild);
if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
if (req.Name is not null && (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64))
return new(GuildOpResultCode.NameInvalid);
if (req.Activity.HasValue && req.Activity.Value is < 1 or > 16)
return new(GuildOpResultCode.NameInvalid);
if (req.JoinCondition.HasValue && req.JoinCondition.Value is < 1 or > 3)
return new(GuildOpResultCode.NameInvalid);
await _guilds.UpdateActivityAndJoinConditionAsync(m.GuildId, req.Activity, req.JoinCondition, req.Name, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> UpdateDescriptionAsync(long viewerId, string description, CancellationToken ct = default)
{
var m = await _members.GetMembershipAsync(viewerId, ct);
if (m is null) return new(GuildOpResultCode.NotInGuild);
if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
if (description.Length > 512) return new(GuildOpResultCode.NameInvalid);
await _guilds.UpdateDescriptionAsync(m.GuildId, description, ct);
await _chat.EmitSystemEventAsync(m.GuildId, viewerId, GuildChatMessageType.Description, body: null, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default)
{
var m = await _members.GetMembershipAsync(viewerId, ct);
if (m is null) return new(GuildOpResultCode.NotInGuild);
if (m.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
await _guilds.UpdateEmblemAsync(m.GuildId, emblemId, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> BreakupAsync(long viewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(viewerId, ct);
if (membership is null) return new(GuildOpResultCode.NotInGuild);
if (membership.Role != GuildRole.Leader) return new(GuildOpResultCode.PermissionDenied);
var now = DateTime.UtcNow;
var members = await _members.ListByGuildAsync(membership.GuildId, ct);
foreach (var m in members) await _viewers.ClearGuildIdAsync(m.ViewerId, ct);
await _chatMessages.DeleteAllForGuildAsync(membership.GuildId, ct);
await _invites.DeleteAllForGuildAsync(membership.GuildId, ct);
await _joinRequests.DeleteAllForGuildAsync(membership.GuildId, ct);
await _members.DeleteAllForGuildAsync(membership.GuildId, ct);
await _guilds.MarkBrokenUpAsync(membership.GuildId, now, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
{
// Caller must be a guild member with Leader or SubLeader role.
var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct);
if (callerMembership is null) return new(GuildOpResultCode.NotInGuild);
if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return new(GuildOpResultCode.PermissionDenied);
// Target must not already be in any guild.
var targetMembership = await _members.GetMembershipAsync(targetViewerId, ct);
if (targetMembership is not null) return new(GuildOpResultCode.AlreadyInGuild);
// No duplicate pending invite to the same target from the same guild.
var existing = await _invites.GetAsync(callerMembership.GuildId, targetViewerId, ct);
if (existing is { Status: GuildInviteStatus.Pending }) return new(GuildOpResultCode.InviteAlreadyResolved);
var now = DateTime.UtcNow;
await _invites.AddAsync(new GuildInvite
{
GuildId = callerMembership.GuildId,
InviteeViewerId = targetViewerId,
InviterViewerId = callerViewerId,
Status = GuildInviteStatus.Pending,
CreatedAt = now,
}, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default)
{
// Caller must be in their guild with Leader or SubLeader role.
var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct);
if (callerMembership is null) return new(GuildOpResultCode.NotInGuild);
if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return new(GuildOpResultCode.PermissionDenied);
var invite = await _invites.GetByIdAsync(inviteId, ct);
if (invite is null) return new(GuildOpResultCode.InviteNotFound);
// Caller must belong to the same guild as the invite.
if (invite.GuildId != callerMembership.GuildId) return new(GuildOpResultCode.PermissionDenied);
if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved);
await _invites.UpdateStatusAsync(invite.GuildId, invite.InviteeViewerId, GuildInviteStatus.Canceled, DateTime.UtcNow, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default)
{
// Invitee rejects — no guild membership check needed.
var invite = await _invites.GetByIdAsync(inviteId, ct);
if (invite is null) return new(GuildOpResultCode.InviteNotFound);
// Caller must be the invitee.
if (invite.InviteeViewerId != callerViewerId) return new(GuildOpResultCode.PermissionDenied);
if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved);
await _invites.UpdateStatusAsync(invite.GuildId, callerViewerId, GuildInviteStatus.Rejected, DateTime.UtcNow, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
{
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
return new(GuildOpResultCode.AlreadyInGuild);
var g = await _guilds.GetActiveByIdAsync(guildId, ct);
if (g is null) return new(GuildOpResultCode.GuildNotFound);
var cfg = _config.Get<GuildConfig>();
var memberCount = await _members.CountByGuildAsync(guildId, ct);
if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached);
var now = DateTime.UtcNow;
bool hasPendingInvite = await _invites.GetAsync(guildId, viewerId, ct)
is { Status: GuildInviteStatus.Pending };
if (g.JoinCondition == GuildJoinCondition.OnlyInvite && !hasPendingInvite)
return new(GuildOpResultCode.PermissionDenied);
if (g.JoinCondition == GuildJoinCondition.Approval && !hasPendingInvite)
{
var existing = await _joinRequests.GetAsync(guildId, viewerId, ct);
if (existing is { Status: GuildJoinRequestStatus.Pending })
{
// Already pending — idempotent, no-op.
return GuildOpResult.Ok;
}
if (existing is not null)
{
// Row exists but was previously Canceled/Rejected — reset to Pending in place.
existing.Status = GuildJoinRequestStatus.Pending;
existing.CreatedAt = now;
existing.RespondedAt = null;
await _db.SaveChangesAsync(ct);
}
else
{
await _joinRequests.AddAsync(new GuildJoinRequest
{
GuildId = guildId,
ViewerId = viewerId,
Status = GuildJoinRequestStatus.Pending,
CreatedAt = now,
}, ct);
}
return GuildOpResult.Ok;
}
await CommitJoinAsync(viewerId, guildId, now, ct);
return GuildOpResult.Ok;
}
private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct)
{
await _members.AddAsync(
new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now },
ct);
await _viewers.SetGuildIdAsync(viewerId, guildId, ct);
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct);
}
public async Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default)
{
// Cancel all pending requests for this viewer (client sends no guildId — see decompile).
var pending = await _joinRequests.ListPendingForViewerAsync(viewerId, ct);
if (pending.Count == 0) return new(GuildOpResultCode.JoinRequestNotFound);
var now = DateTime.UtcNow;
foreach (var req in pending)
await _joinRequests.UpdateStatusAsync(req.GuildId, viewerId, GuildJoinRequestStatus.Canceled, now, ct);
return GuildOpResult.Ok;
}
public Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<GuildOpResult> RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<GuildOpResult> LeaveAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<GuildOpResult> RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default)
=> _invites.ListPendingForInviteeAsync(viewerId, ct);
public async Task<IReadOnlyList<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(callerViewerId, ct);
if (membership is null) return Array.Empty<GuildOutgoingInviteEntry>();
var pendingInvites = await _invites.ListPendingForGuildAsync(membership.GuildId, ct);
if (pendingInvites.Count == 0) return Array.Empty<GuildOutgoingInviteEntry>();
var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(inviteeIds, ct);
// Load emblem + degree data for invitees.
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => inviteeIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
var result = new List<GuildOutgoingInviteEntry>(pendingInvites.Count);
foreach (var invite in pendingInvites)
{
viewerRows.TryGetValue(invite.InviteeViewerId, out var v);
result.Add(new GuildOutgoingInviteEntry(
InviteId: invite.Id,
InviteeViewerId: invite.InviteeViewerId,
Name: names.GetValueOrDefault(invite.InviteeViewerId, ""),
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode: v?.Info?.CountryCode ?? "",
Rank: 1,
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
CreatedAt: invite.CreatedAt));
}
return result;
}
public async Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default)
{
// ListPendingForInviteeAsync already includes the Guild nav prop.
var pendingInvites = await _invites.ListPendingForInviteeAsync(viewerId, ct);
if (pendingInvites.Count == 0) return Array.Empty<GuildReceivedInviteEntry>();
var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList();
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
var leaderIds = pendingInvites.Select(i => i.Guild.LeaderViewerId).Distinct().ToList();
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
return pendingInvites.Select(invite => new GuildReceivedInviteEntry(
InviteId: invite.Id,
Guild: invite.Guild,
MemberNum: memberCounts.GetValueOrDefault(invite.GuildId, 0),
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
}
public async Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(viewerId, ct);
if (membership is null) return Array.Empty<GuildJoinRequestEntry>();
// Only leader/subleader may view the list — return empty for regular members.
if (membership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return Array.Empty<GuildJoinRequestEntry>();
var requests = await _joinRequests.ListPendingForGuildAsync(membership.GuildId, ct);
if (requests.Count == 0) return Array.Empty<GuildJoinRequestEntry>();
var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct);
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => applicantIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
return requests.Select(r =>
{
viewerRows.TryGetValue(r.ViewerId, out var v);
return new GuildJoinRequestEntry(
ApplicantViewerId: r.ViewerId,
Name: names.GetValueOrDefault(r.ViewerId, ""),
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode: v?.Info?.CountryCode ?? "",
Rank: 1,
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true,
RequestedAt: r.CreatedAt);
}).ToList();
}
}