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

View File

@@ -1,8 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Entities.Guild;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.Database.Services.Guild;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
using SVSim.EmulatedEntrypoint.Models.Dtos.Guild;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
@@ -14,11 +19,16 @@ public sealed class GuildController : SVSimController
{
private readonly IGuildService _guild;
private readonly IGameConfigService _configs;
private readonly SVSimDbContext _db;
public GuildController(IGuildService guild, IGameConfigService configs)
// 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)
{
_guild = guild;
_configs = configs;
_db = db;
}
[HttpPost("info")]
@@ -39,8 +49,14 @@ public sealed class GuildController : SVSimController
}
else
{
resp.GuildStatus = 2; // JOINING — fully populated path lands in Task 7
// Task 7 populates `Guild`, JoinRequestCount, InviteCount.
resp.GuildStatus = 2; // JOINING
resp.JoinRequestCount = view.JoinRequestCount;
resp.InviteCount = view.InviteCount;
resp.Guild = new GuildBundle
{
Detail = ToDetailDto(view.Guild, view.Members.Count),
Members = await ToMemberDtoListAsync(view.Members, viewerId, ct),
};
}
return resp;
}
@@ -48,7 +64,13 @@ public sealed class GuildController : SVSimController
// ===== 21 remaining stubs — each returns the response DTO with defaults =====
[HttpPost("create")]
public Task<ActionResult<EmptyResponse>> Create([FromBody] GuildCreateRequest req, CancellationToken ct) => Stub();
public async Task<ActionResult<EmptyResponse>> Create([FromBody] GuildCreateRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var res = await _guild.CreateAsync(viewerId, new(req.GuildName, req.Activity, req.JoinCondition), ct);
if (res.IsOk) return new EmptyResponse();
return MapErrorToWire(res);
}
[HttpPost("breakup")]
public Task<ActionResult<EmptyResponse>> Breakup([FromBody] BaseRequest _, CancellationToken ct) => Stub();
@@ -124,6 +146,68 @@ public sealed class GuildController : SVSimController
public Task<ActionResult<GuildChangeRoleResponse>> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct)
=> Task.FromResult<ActionResult<GuildChangeRoleResponse>>(new GuildChangeRoleResponse { Members = new() });
// ===== Private helpers =====
private static GuildDetailDto ToDetailDto(SVSim.Database.Entities.Guild.Guild guild, int memberCount) => new()
{
GuildId = guild.GuildId,
GuildName = guild.Name,
Description = guild.Description,
GuildEmblemId = guild.EmblemId,
JoinCondition = (int)guild.JoinCondition,
Activity = (int)guild.Activity,
MemberNum = memberCount,
LeaderViewerId = guild.LeaderViewerId,
// LeaderName will be filled via members list join — leave "" here; caller populates it from member data if needed.
LeaderName = "",
};
private async Task<List<GuildMemberInfoDto>> ToMemberDtoListAsync(
IReadOnlyList<GuildMember> members,
long callerViewerId,
CancellationToken ct)
{
if (members.Count == 0) return new();
var viewerIds = members.Select(m => m.ViewerId).ToList();
// Batch-load viewer rows with Info + SelectedEmblem + SelectedDegree.
var viewers = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => viewerIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
var result = new List<GuildMemberInfoDto>(members.Count);
foreach (var m in members)
{
viewers.TryGetValue(m.ViewerId, out var v);
result.Add(ToMemberDto(m, v));
}
return result;
}
private static GuildMemberInfoDto ToMemberDto(GuildMember member, Viewer? viewer) => new()
{
ViewerId = member.ViewerId,
Name = viewer?.DisplayName ?? "",
EmblemId = viewer?.Info?.SelectedEmblem?.Id is > 0 ? viewer.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode = viewer?.Info?.CountryCode ?? "",
Rank = 1, // TODO: populate from actual rank data when rank tracking lands
DegreeId = viewer?.Info?.SelectedDegree?.Id ?? 0,
IsOfficialMarkDisplayed = viewer?.Info?.IsOfficialMarkDisplayed == true ? 1 : 0,
Role = (int)member.Role,
};
/// <summary>
/// Maps a failed GuildOpResult to the wire error envelope convention used throughout the
/// codebase (see CampaignController.Fail(), AchievementController, MissionController).
/// Returns HTTP 200 with a JSON body carrying <c>result_code: 2</c>.
/// </summary>
private ActionResult<EmptyResponse> MapErrorToWire(GuildOpResult res)
=> Ok(new { result_code = GuildErrorResultCode });
private static Task<ActionResult<EmptyResponse>> Stub() =>
Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
}

View File

@@ -126,6 +126,7 @@ public class Program
builder.Services.AddScoped<SVSim.Database.Repositories.Guild.IGuildInviteRepository, SVSim.Database.Repositories.Guild.GuildInviteRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.Guild.IGuildJoinRequestRepository, SVSim.Database.Repositories.Guild.GuildJoinRequestRepository>();
builder.Services.AddScoped<SVSim.Database.Repositories.Guild.IGuildChatMessageRepository, SVSim.Database.Repositories.Guild.GuildChatMessageRepository>();
builder.Services.AddSingleton<SVSim.Database.Services.Guild.IGuildIdGenerator, SVSim.Database.Services.Guild.GuildIdGenerator>();
builder.Services.AddScoped<SVSim.Database.Services.Guild.IGuildService, SVSim.Database.Services.Guild.GuildService>();
builder.Services.AddScoped<SVSim.Database.Services.Guild.IGuildChatService, SVSim.Database.Services.Guild.GuildChatService>();

View File

@@ -0,0 +1,87 @@
using System.Net.Http.Json;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Integration.Guild;
public class GuildCreateInfoFlowTests
{
[Test]
public async Task PostingCreate_then_Info_returns_populated_guild()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(
steamId: 76_561_198_200_000_001UL,
displayName: "AlphaIntPlayer");
using var client = factory.CreateAuthenticatedClient(viewerId);
// POST /guild/create — mirror the arena integration test style: include the base-request fields
const string Vid = "0"; const int Sid = 0; const string Stk = "";
var create = await client.PostAsync("/guild/create",
System.Net.Http.Json.JsonContent.Create(new { guild_name = "AlphaInt", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
Assert.That(create.IsSuccessStatusCode, Is.True, $"create failed ({create.StatusCode}): {await create.Content.ReadAsStringAsync()}");
// POST /guild/info — expect JOINING state with guild populated
var info = await client.PostAsync("/guild/info",
System.Net.Http.Json.JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var json = await info.Content.ReadAsStringAsync();
Assert.That(info.IsSuccessStatusCode, Is.True, $"info failed: {json}");
// In test mode (no UnityPlayer user-agent) the translation middleware is a no-op;
// the controller's raw DTO is returned directly (no {data_headers, data} envelope).
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.That(root.GetProperty("guild_status").GetString(), Is.EqualTo("2"),
"guild_status should be JOINING (2)");
Assert.That(root.GetProperty("guild").GetProperty("detail").GetProperty("guild_name").GetString(),
Is.EqualTo("AlphaInt"));
Assert.That(root.GetProperty("guild").GetProperty("members").GetArrayLength(),
Is.EqualTo(1));
}
[Test]
public async Task PostingCreate_twice_returns_error_second_time()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(
steamId: 76_561_198_200_000_002UL,
displayName: "DoubleCreatePlayer");
using var client = factory.CreateAuthenticatedClient(viewerId);
const string Vid = "0"; const int Sid = 0; const string Stk = "";
var createBody = new { guild_name = "OnlyOnce", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk };
// First create should succeed
var first = await client.PostAsync("/guild/create",
System.Net.Http.Json.JsonContent.Create(createBody));
Assert.That(first.IsSuccessStatusCode, Is.True, $"First create failed: {await first.Content.ReadAsStringAsync()}");
var firstJson = await first.Content.ReadAsStringAsync();
using var firstDoc = JsonDocument.Parse(firstJson);
// In test mode (no envelope), result_code may be present (failure) or absent (success EmptyResponse).
// Success path returns EmptyResponse ({}), so result_code will not be present.
var firstRoot = firstDoc.RootElement;
if (firstRoot.TryGetProperty("result_code", out var rc1))
{
// If present it must not be 2 (error code).
Assert.That(rc1.GetInt32(), Is.Not.EqualTo(2), "First create must not error");
}
// Second create for the same viewer should return an error envelope (result_code = 2).
var secondBody = new { guild_name = "OnlyOnceAgain", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk };
var second = await client.PostAsync("/guild/create",
System.Net.Http.Json.JsonContent.Create(secondBody));
Assert.That(second.IsSuccessStatusCode, Is.True, "HTTP level should still be 200");
var secondJson = await second.Content.ReadAsStringAsync();
using var secondDoc = JsonDocument.Parse(secondJson);
// Error path: the MapErrorToWire helper returns { result_code = 2 }
Assert.That(secondDoc.RootElement.TryGetProperty("result_code", out var rc2), Is.True,
$"Second create should return result_code field, got: {secondJson}");
Assert.That(rc2.GetInt32(), Is.EqualTo(2), "Second create should fail with result_code=2");
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database.Entities.Guild;
using SVSim.Database.Services.Guild;
using SVSim.UnitTests.Infrastructure;
using GuildEntity = SVSim.Database.Entities.Guild.Guild;
namespace SVSim.UnitTests.Services.Guild;
public class GuildServiceCreateTests
{
private static async Task<long> CreateViewerAsync(SVSimTestFactory factory, ulong steamId = 76_561_198_100_000_001UL, string name = "GuildTestViewer")
=> await factory.SeedViewerAsync(steamId: steamId, displayName: name);
private static IGuildService GuildService(SVSimTestFactory factory, out IServiceScope scope)
{
scope = factory.Services.CreateScope();
return scope.ServiceProvider.GetRequiredService<IGuildService>();
}
[Test]
public async Task CreateAsync_makes_a_guild_with_caller_as_leader()
{
using var factory = new SVSimTestFactory();
var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_001UL, "Alpha Leader");
GuildOpResult res;
using (var scope = factory.Services.CreateScope())
{
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
res = await svc.CreateAsync(viewerId, new("Alpha", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
}
Assert.That(res.IsOk, Is.True);
Assert.That(res.GuildId, Is.Not.Null);
GuildFullView? view;
using (var scope = factory.Services.CreateScope())
{
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
view = await svc.GetMyGuildAsync(viewerId);
}
Assert.That(view, Is.Not.Null);
Assert.That(view!.Guild.Name, Is.EqualTo("Alpha"));
Assert.That(view.Guild.LeaderViewerId, Is.EqualTo(viewerId));
Assert.That(view.Members.Single().Role, Is.EqualTo(GuildRole.Leader));
}
[Test]
public async Task CreateAsync_returns_AlreadyInGuild_when_viewer_already_in_a_guild()
{
using var factory = new SVSimTestFactory();
var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_002UL, "Already In");
using (var scope = factory.Services.CreateScope())
{
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
var first = await svc.CreateAsync(viewerId, new("FirstGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
Assert.That(first.IsOk, Is.True);
}
using (var scope = factory.Services.CreateScope())
{
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
var second = await svc.CreateAsync(viewerId, new("SecondGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
Assert.That(second.Code, Is.EqualTo(GuildOpResultCode.AlreadyInGuild));
}
}
[Test]
public async Task CreateAsync_returns_NameTaken_when_guild_name_already_exists()
{
using var factory = new SVSimTestFactory();
var v1 = await CreateViewerAsync(factory, 76_561_198_100_000_003UL, "Viewer1");
var v2 = await CreateViewerAsync(factory, 76_561_198_100_000_004UL, "Viewer2");
using (var scope = factory.Services.CreateScope())
{
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
var first = await svc.CreateAsync(v1, new("SameName", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
Assert.That(first.IsOk, Is.True);
}
using (var scope = factory.Services.CreateScope())
{
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
var second = await svc.CreateAsync(v2, new("SameName", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
Assert.That(second.Code, Is.EqualTo(GuildOpResultCode.NameTaken));
}
}
[Test]
public async Task CreateAsync_returns_NameInvalid_for_empty_name()
{
using var factory = new SVSimTestFactory();
var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_005UL);
using var scope = factory.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
var res = await svc.CreateAsync(viewerId, new("", (int)GuildActivity.All, (int)GuildJoinCondition.Free));
Assert.That(res.Code, Is.EqualTo(GuildOpResultCode.NameInvalid));
}
[Test]
public async Task GetMyGuildAsync_returns_null_for_viewer_not_in_any_guild()
{
using var factory = new SVSimTestFactory();
var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_006UL);
using var scope = factory.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IGuildService>();
var view = await svc.GetMyGuildAsync(viewerId);
Assert.That(view, Is.Null);
}
}