feat(guild): /guild/create + populated /guild/info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 12:17:22 -04:00
parent 99019963ca
commit f7f68d03a7
8 changed files with 430 additions and 11 deletions

View File

@@ -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);
}

View File

@@ -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

View 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.");
}
}

View File

@@ -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();