Files
SVSimServer/SVSim.Database/Services/Guild/GuildService.cs
gamer147 1b7fa2d525 feat(guild): invite state machine
Add surrogate PK (IDENTITY bigint) to GuildInvite for wire invite_id.
Implement InviteAsync, CancelInviteAsync, RejectInviteAsync,
ListOutgoingInvitesAsync, ListInvitedGuildsAsync in GuildService.
Wire 5 endpoints: invite_user_list, invited_guild_list, invite,
cancel_invite, reject_invite in GuildController.
Migration: AddGuildInviteSurrogatePk — drops composite PK, adds Id
IDENTITY column, adds unique index on (GuildId, InviteeViewerId).
6 integration tests in GuildInviteFlowTests all green.

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

334 lines
16 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 Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
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 Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
}