feat(guild): /guild/create + populated /guild/info
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -27,4 +27,10 @@ public interface IViewerRepository
|
||||
/// doesn't exist.
|
||||
/// </summary>
|
||||
Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId);
|
||||
|
||||
/// <summary>Sets Viewer.GuildId to <paramref name="guildId"/>. No-op if the viewer does not exist.</summary>
|
||||
Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Clears Viewer.GuildId to null. No-op if the viewer does not exist.</summary>
|
||||
Task ClearGuildIdAsync(long viewerId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -268,6 +268,24 @@ public class ViewerRepository : IViewerRepository
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
public async Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default)
|
||||
{
|
||||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
if (viewer is null) return;
|
||||
viewer.GuildId = guildId;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task ClearGuildIdAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
if (viewer is null) return;
|
||||
viewer.GuildId = null;
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
||||
{
|
||||
Models.Viewer viewer = new Models.Viewer
|
||||
|
||||
26
SVSim.Database/Services/Guild/GuildIdGenerator.cs
Normal file
26
SVSim.Database/Services/Guild/GuildIdGenerator.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace SVSim.Database.Services.Guild;
|
||||
|
||||
public interface IGuildIdGenerator
|
||||
{
|
||||
Task<int> NextAsync(Func<int, CancellationToken, Task<bool>> existsAsync, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class GuildIdGenerator : IGuildIdGenerator
|
||||
{
|
||||
private static readonly System.Security.Cryptography.RandomNumberGenerator _rng =
|
||||
System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
|
||||
public async Task<int> NextAsync(Func<int, CancellationToken, Task<bool>> existsAsync, CancellationToken ct)
|
||||
{
|
||||
const int min = 100_000_000, max = 999_999_999;
|
||||
const int attempts = 16;
|
||||
for (int i = 0; i < attempts; i++)
|
||||
{
|
||||
var bytes = new byte[4]; _rng.GetBytes(bytes);
|
||||
var raw = BitConverter.ToUInt32(bytes, 0);
|
||||
int id = min + (int)(raw % (uint)(max - min + 1));
|
||||
if (!await existsAsync(id, ct)) return id;
|
||||
}
|
||||
throw new InvalidOperationException("Exhausted guild_id collision retries — id space too saturated.");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.Database.Repositories.Guild;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Database.Services.Guild;
|
||||
@@ -11,6 +14,10 @@ public sealed class GuildService : IGuildService
|
||||
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,
|
||||
@@ -18,7 +25,11 @@ public sealed class GuildService : IGuildService
|
||||
IGuildInviteRepository invites,
|
||||
IGuildJoinRequestRepository joinRequests,
|
||||
IGuildChatMessageRepository chatMessages,
|
||||
IGameConfigService config)
|
||||
IGameConfigService config,
|
||||
IGuildIdGenerator idGen,
|
||||
IGuildChatService chat,
|
||||
IViewerRepository viewers,
|
||||
SVSimDbContext db)
|
||||
{
|
||||
_guilds = guilds;
|
||||
_members = members;
|
||||
@@ -26,20 +37,91 @@ public sealed class GuildService : IGuildService
|
||||
_joinRequests = joinRequests;
|
||||
_chatMessages = chatMessages;
|
||||
_config = config;
|
||||
_idGen = idGen;
|
||||
_chat = chat;
|
||||
_viewers = viewers;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
// Returns null so /guild/info can short-circuit to a non-joined response in Phase 1.
|
||||
public Task<GuildFullView?> GetMyGuildAsync(long viewerId, CancellationToken ct = default)
|
||||
=> Task.FromResult<GuildFullView?>(null);
|
||||
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)
|
||||
=> throw new NotImplementedException();
|
||||
=> _guilds.GetActiveByIdAsync(guildId, ct);
|
||||
|
||||
public Task<IReadOnlyList<GuildSearchEntry>> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<GuildOpResult> CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
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 Task<GuildOpResult> UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
Reference in New Issue
Block a user