diff --git a/SVSim.Database/Repositories/Guild/GuildRepository.cs b/SVSim.Database/Repositories/Guild/GuildRepository.cs index 28a9974e..d874e332 100644 --- a/SVSim.Database/Repositories/Guild/GuildRepository.cs +++ b/SVSim.Database/Repositories/Guild/GuildRepository.cs @@ -70,10 +70,11 @@ public sealed class GuildRepository : IGuildRepository return await q.Take(resultCap).ToListAsync(ct); } - public async Task UpdateActivityAndJoinConditionAsync(int guildId, int? activity, int? joinCondition, CancellationToken ct = default) + public async Task UpdateActivityAndJoinConditionAsync(int guildId, int? activity, int? joinCondition, string? name = null, CancellationToken ct = default) { var guild = await _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct); if (guild is null) return; + if (!string.IsNullOrWhiteSpace(name)) guild.Name = name; if (activity.HasValue) guild.Activity = (Entities.Guild.GuildActivity)activity.Value; if (joinCondition.HasValue) guild.JoinCondition = (Entities.Guild.GuildJoinCondition)joinCondition.Value; await _db.SaveChangesAsync(ct); diff --git a/SVSim.Database/Repositories/Guild/IGuildRepository.cs b/SVSim.Database/Repositories/Guild/IGuildRepository.cs index e5b41b91..ac5a7446 100644 --- a/SVSim.Database/Repositories/Guild/IGuildRepository.cs +++ b/SVSim.Database/Repositories/Guild/IGuildRepository.cs @@ -25,8 +25,8 @@ public interface IGuildRepository string name, int activity, int joinCondition, int memberBucket, int maxMemberCap, int resultCap, CancellationToken ct = default); - /// Updates activity and/or join_condition. Only non-null fields are written. - Task UpdateActivityAndJoinConditionAsync(int guildId, int? activity, int? joinCondition, CancellationToken ct = default); + /// Updates name, activity, and/or join_condition. Only non-null fields are written. + Task UpdateActivityAndJoinConditionAsync(int guildId, int? activity, int? joinCondition, string? name = null, CancellationToken ct = default); /// Overwrites the description field. Task UpdateDescriptionAsync(int guildId, string description, CancellationToken ct = default); diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index af0d0ec2..547faf34 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -129,12 +129,14 @@ public sealed class GuildService : IGuildService 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, ct); + await _guilds.UpdateActivityAndJoinConditionAsync(m.GuildId, req.Activity, req.JoinCondition, req.Name, ct); return GuildOpResult.Ok; } diff --git a/SVSim.Database/Services/Guild/GuildServiceTypes.cs b/SVSim.Database/Services/Guild/GuildServiceTypes.cs index 04ac0083..df0ab382 100644 --- a/SVSim.Database/Services/Guild/GuildServiceTypes.cs +++ b/SVSim.Database/Services/Guild/GuildServiceTypes.cs @@ -37,4 +37,4 @@ public sealed record GuildFullView( public sealed record GuildSearchEntry(Entities.Guild.Guild Guild, int MemberNum); public sealed record CreateGuildRequest(string Name, int Activity, int JoinCondition); -public sealed record UpdateGuildRequest(int? Activity, int? JoinCondition); +public sealed record UpdateGuildRequest(int? Activity, int? JoinCondition, string? Name = null); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 9cc1aebf..9183b705 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -88,7 +88,8 @@ public sealed class GuildController : SVSimController if (!TryGetViewerId(out var viewerId)) return Unauthorized(); int? activity = req.Activity != 0 ? req.Activity : null; int? joinCondition = req.JoinCondition != 0 ? req.JoinCondition : null; - var r = await _guild.UpdateAsync(viewerId, new(activity, joinCondition), ct); + string? name = string.IsNullOrWhiteSpace(req.GuildName) ? null : req.GuildName; + var r = await _guild.UpdateAsync(viewerId, new(activity, joinCondition, name), ct); if (!r.IsOk) return WireError(); var m = await _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct); @@ -97,10 +98,8 @@ public sealed class GuildController : SVSimController if (guild is null) return WireError(); var memberCount = await _db.GuildMembers.CountAsync(x => x.GuildId == guild.GuildId, ct); var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, ct); - return new GuildUpdateResponse - { - Guild = new GuildDetailSubTree { Detail = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "") } - }; + // GuildUpdateTask.Parse() reads data["guild"] directly as GuildDetailInfo — flat, no "detail" wrapper. + return new GuildUpdateResponse { Guild = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "") }; } [HttpPost("update_description")] @@ -112,7 +111,7 @@ public sealed class GuildController : SVSimController } [HttpPost("update_emblem")] - public async Task> UpdateEmblem([FromBody] GuildUpdateEmblemRequest req, CancellationToken ct) + public async Task> UpdateEmblem([FromBody] GuildUpdateEmblemRequest req, CancellationToken ct) { if (!TryGetViewerId(out var viewerId)) return Unauthorized(); var r = await _guild.UpdateEmblemAsync(viewerId, req.EmblemId, ct); @@ -124,7 +123,8 @@ public sealed class GuildController : SVSimController if (guild is null) return WireError(); var memberCount = await _db.GuildMembers.CountAsync(x => x.GuildId == guild.GuildId, ct); var leader = await _db.Viewers.AsNoTracking().FirstOrDefaultAsync(v => v.Id == guild.LeaderViewerId, ct); - return new GuildUpdateResponse + // GuildEmblemUpdateTask.Parse() reads data["guild"]["detail"] — nested wrapper required. + return new GuildUpdateEmblemResponse { Guild = new GuildDetailSubTree { Detail = ToDetailDto(guild, memberCount, leader?.DisplayName ?? "") } }; diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildUpdateResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildUpdateResponse.cs index 9bcf4206..51e68957 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildUpdateResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Guild/GuildUpdateResponse.cs @@ -5,11 +5,22 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Guild; /// -/// Response for POST /guild/update and POST /guild/update_emblem. -/// Returns the full updated guild detail sub-tree; client merges without re-fetching members. +/// Response for POST /guild/update. +/// GuildUpdateTask.Parse() reads data["guild"] directly as GuildDetailInfo — flat, no "detail" wrapper. /// [MessagePackObject] public class GuildUpdateResponse +{ + [JsonPropertyName("guild"), Key("guild")] + public GuildDetailDto Guild { get; set; } = new(); +} + +/// +/// Response for POST /guild/update_emblem. +/// GuildEmblemUpdateTask.Parse() reads data["guild"]["detail"] — requires the nested wrapper. +/// +[MessagePackObject] +public class GuildUpdateEmblemResponse { [JsonPropertyName("guild"), Key("guild")] public GuildDetailSubTree Guild { get; set; } = new(); diff --git a/SVSim.UnitTests/Wire/GuildWireShape.cs b/SVSim.UnitTests/Wire/GuildWireShape.cs index effd1d6d..cb6876b8 100644 --- a/SVSim.UnitTests/Wire/GuildWireShape.cs +++ b/SVSim.UnitTests/Wire/GuildWireShape.cs @@ -1,5 +1,6 @@ using NUnit.Framework; using System.Text.Json; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Guild; namespace SVSim.UnitTests.Wire; @@ -35,6 +36,55 @@ public class GuildWireShape Assert.That(first.GetProperty("emblem_id").GetString(), Is.EqualTo("100000001")); } + private static GuildDetailDto SampleDetail => new() + { + GuildId = 100_000_001, + GuildName = "X", + LeaderName = "L", + LeaderViewerId = 7, + Activity = 1, + JoinCondition = 1, + MemberNum = 1, + GuildEmblemId = 100_000_000L, + Description = "", + }; + + [Test] + public void GuildUpdate_response_serializes_with_flat_guild_detail() + { + // GuildUpdateTask.Parse() reads data["guild"] as GuildDetailInfo directly — no "detail" wrapper. + var resp = new GuildUpdateResponse { Guild = SampleDetail }; + var json = JsonSerializer.Serialize(resp, Opts); + using var doc = JsonDocument.Parse(json); + var guild = doc.RootElement.GetProperty("guild"); + + // guild_id must be DIRECTLY under "guild" + Assert.That(guild.TryGetProperty("guild_id", out var gid), Is.True, "guild_id must be directly under guild"); + Assert.That(gid.GetString(), Is.EqualTo("100000001")); + + // No nested "detail" wrapper + Assert.That(guild.TryGetProperty("detail", out _), Is.False, "no detail wrapper for /guild/update"); + } + + [Test] + public void GuildUpdateEmblem_response_serializes_with_nested_detail() + { + // GuildEmblemUpdateTask.Parse() reads data["guild"]["detail"] — wrapper is required. + var resp = new GuildUpdateEmblemResponse + { + Guild = new GuildDetailSubTree { Detail = SampleDetail } + }; + var json = JsonSerializer.Serialize(resp, Opts); + using var doc = JsonDocument.Parse(json); + var guildNode = doc.RootElement.GetProperty("guild"); + + Assert.That(guildNode.TryGetProperty("detail", out var detail), Is.True, "detail wrapper must exist for /guild/update_emblem"); + Assert.That(detail.GetProperty("guild_id").GetString(), Is.EqualTo("100000001")); + + // guild_id must NOT be directly under "guild" + Assert.That(guildNode.TryGetProperty("guild_id", out _), Is.False, "guild_id must be inside detail, not at guild level"); + } + [Test] public void GuildInfo_non_joined_serializes_to_prod_shape() {