From eb65c2081ced737358e15f80f9b6d689f12924e1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 15:14:06 -0400 Subject: [PATCH] feat(guild_chat): deck + replay attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement all five guild_chat attachment endpoints: - add_deck: server looks up deck by (viewerId, format, deckNo) and snapshots the current state into a DECK-type chat message DeckPayload column. - delete_deck: author OR leader/sub-leader may clear; returns refreshed deck_log. Extended IGuildChatMessageRepository.ClearDeckAsync with leaderOverride flag; added GetDeckMessagesAsync + GetByMessageIdAsync. - add_replay: stores {"battle_id": N} in ReplayPayload; full shape is TODO(post-merge) pending replay-subsystem capture. - replay_detail: returns the stored ReplayPayload as JsonElement passthrough. - deck_log: returns all active DECK messages grouped by API-side Format string key ("1"=Rotation, "2"=Unlimited, "3"=PreRotation always present; "4"=Crossover, "5"=MyRotation only when non-empty). Adds DeckLogDataDto (typed) for deck_log / delete_deck responses. Replay response uses JsonElement passthrough (TODO(post-merge)). 8 new integration tests — all green. Full suite 1530/1530 pass. Co-Authored-By: Claude Sonnet 4.6 --- .../Guild/GuildChatMessageRepository.cs | 16 +- .../Guild/IGuildChatMessageRepository.cs | 17 +- .../Services/Guild/GuildChatService.cs | 118 ++++- .../Services/Guild/IGuildChatService.cs | 48 +- .../Controllers/GuildChatController.cs | 168 ++++++- .../Dtos/Common/Guild/DeckLogDataDto.cs | 65 +++ .../GuildChat/GuildChatDeckLogResponse.cs | 16 +- .../GuildChat/GuildChatDeleteDeckResponse.cs | 15 +- .../GuildChatReplayDetailResponse.cs | 13 +- .../Guild/GuildChatAttachmentTests.cs | 416 ++++++++++++++++++ .../Services/Guild/GuildServiceUpdateTests.cs | 8 +- 11 files changed, 846 insertions(+), 54 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/DeckLogDataDto.cs create mode 100644 SVSim.UnitTests/Integration/Guild/GuildChatAttachmentTests.cs diff --git a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs index 1338baf3..c20bf1f8 100644 --- a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs +++ b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs @@ -150,13 +150,25 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository await _db.SaveChangesAsync(ct); } - public async Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default) + public Task GetByMessageIdAsync(int guildId, int messageId, CancellationToken ct = default) + => _db.GuildChatMessages + .FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct); + + public async Task> GetDeckMessagesAsync(int guildId, CancellationToken ct = default) + => await _db.GuildChatMessages + .Where(m => m.GuildId == guildId + && m.MessageType == GuildChatMessageType.Deck + && m.DeckPayload != null) + .OrderBy(m => m.MessageId) + .ToListAsync(ct); + + public async Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, bool leaderOverride, CancellationToken ct = default) { var msg = await _db.GuildChatMessages .FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct); if (msg is null) return false; - if (msg.AuthorViewerId != callerViewerId) return false; + if (!leaderOverride && msg.AuthorViewerId != callerViewerId) return false; if (msg.DeckPayload is null) return false; msg.DeckPayload = null; diff --git a/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs index 4a76f688..8e018105 100644 --- a/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs +++ b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs @@ -14,7 +14,18 @@ public interface IGuildChatMessageRepository Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default); Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); - /// For /guild_chat/delete_deck: clears the deck payload + body without removing the message row, - /// so the slot remains in the timeline ("[deleted]"). Returns false if not found / not deletable by caller. - Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default); + /// Fetch a single message by (guildId, messageId). Returns null if not found. + Task GetByMessageIdAsync(int guildId, int messageId, CancellationToken ct = default); + + /// + /// Fetch all DECK-type messages for a guild (non-null DeckPayload only — clears are excluded). + /// + Task> GetDeckMessagesAsync(int guildId, CancellationToken ct = default); + + /// + /// For /guild_chat/delete_deck: clears the deck payload + body without removing the message row, + /// so the slot remains in the timeline ("[deleted]"). Returns false if not found / not deletable by caller. + /// bypasses the author check — pass true when caller is Leader or SubLeader. + /// + Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, bool leaderOverride, CancellationToken ct = default); } diff --git a/SVSim.Database/Services/Guild/GuildChatService.cs b/SVSim.Database/Services/Guild/GuildChatService.cs index 18ec630e..61c6bc5d 100644 --- a/SVSim.Database/Services/Guild/GuildChatService.cs +++ b/SVSim.Database/Services/Guild/GuildChatService.cs @@ -1,5 +1,8 @@ +using System.Text.Json; using SVSim.Database.Entities.Guild; +using SVSim.Database.Enums; using SVSim.Database.Models.Config; +using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Guild; using SVSim.Database.Services; @@ -10,15 +13,18 @@ public sealed class GuildChatService : IGuildChatService private readonly IGuildMemberRepository _members; private readonly IGuildChatMessageRepository _msgs; private readonly IGameConfigService _cfg; + private readonly IDeckRepository _decks; public GuildChatService( IGuildMemberRepository members, IGuildChatMessageRepository msgs, - IGameConfigService cfg) + IGameConfigService cfg, + IDeckRepository decks) { _members = members; _msgs = msgs; _cfg = cfg; + _decks = decks; } // ── System-event emission ──────────────────────────────────────────────── @@ -98,18 +104,108 @@ public sealed class GuildChatService : IGuildChatService return new(true, stored.MessageId); } - public Task PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default) - => throw new NotImplementedException(); + // ── Deck attachment ────────────────────────────────────────────────────── - public Task DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task PostDeckAsync(long viewerId, Format format, int deckFormatApi, int deckNo, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(viewerId, ct); + if (m is null) return new(false, null); - public Task PostReplayAsync(long viewerId, string replayPayloadJson, CancellationToken ct = default) - => throw new NotImplementedException(); + var deck = await _decks.GetDeck(viewerId, format, deckNo); + if (deck is null) return new(false, null); - public Task GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default) - => throw new NotImplementedException(); + // Build the deck payload JSON that the client expects to parse via DeckLogData / DeckData.Initialize. + // Fields: deck_format (API int), deck_no, deck_name, class_id, sleeve_id, leader_skin_id, card_id_array. + var cardIdArray = deck.Cards + .SelectMany(c => Enumerable.Repeat(c.Card.Id, c.Count)) + .ToList(); - public Task GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default) - => throw new NotImplementedException(); + var payload = new + { + deck_format = deckFormatApi, + deck_no = deck.Number, + deck_name = deck.Name, + class_id = deck.Class.Id, + sleeve_id = (long)deck.Sleeve.Id, + leader_skin_id = deck.LeaderSkin.Id, + card_id_array = cardIdArray, + }; + var payloadJson = JsonSerializer.Serialize(payload); + + var stored = await _msgs.AppendAsync(new GuildChatMessage + { + GuildId = m.GuildId, + AuthorViewerId = viewerId, + MessageType = GuildChatMessageType.Deck, + Body = "", + DeckPayload = payloadJson, + CreatedAt = DateTime.UtcNow, + }, ct); + return new(true, stored.MessageId); + } + + public async Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(viewerId, ct); + if (m is null) return (false, null); + + bool isLeader = m.Role is GuildRole.Leader or GuildRole.SubLeader; + bool cleared = await _msgs.ClearDeckAsync(m.GuildId, messageId, viewerId, isLeader, ct); + if (!cleared) return (false, null); + + var log = await BuildDeckLogAsync(m.GuildId, ct); + return (true, log); + } + + // ── Replay attachment ──────────────────────────────────────────────────── + + public async Task PostReplayAsync(long viewerId, long battleId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(viewerId, ct); + if (m is null) return new(false, null); + + // Store minimal payload; replay subsystem detail is TODO(post-merge). + var payloadJson = JsonSerializer.Serialize(new { battle_id = battleId }); + + var stored = await _msgs.AppendAsync(new GuildChatMessage + { + GuildId = m.GuildId, + AuthorViewerId = viewerId, + MessageType = GuildChatMessageType.Replay, + Body = "", + ReplayPayload = payloadJson, + CreatedAt = DateTime.UtcNow, + }, ct); + return new(true, stored.MessageId); + } + + public async Task GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(viewerId, ct); + if (m is null) return null; + + var msg = await _msgs.GetByMessageIdAsync(m.GuildId, messageId, ct); + return msg?.ReplayPayload; + } + + // ── Deck log ───────────────────────────────────────────────────────────── + + public async Task GetDeckLogAsync(long viewerId, CancellationToken ct = default) + { + var m = await _members.GetMembershipAsync(viewerId, ct); + if (m is null) return null; + return await BuildDeckLogAsync(m.GuildId, ct); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + private async Task BuildDeckLogAsync(int guildId, CancellationToken ct) + { + var deckMessages = await _msgs.GetDeckMessagesAsync(guildId, ct); + var entries = deckMessages + .Where(msg => msg.DeckPayload is not null) + .Select(msg => new DeckLogEntry(msg.MessageId, msg.AuthorViewerId, msg.DeckPayload!)) + .ToList(); + return new DeckLogResult(entries); + } } diff --git a/SVSim.Database/Services/Guild/IGuildChatService.cs b/SVSim.Database/Services/Guild/IGuildChatService.cs index 1f15c2a4..4369745f 100644 --- a/SVSim.Database/Services/Guild/IGuildChatService.cs +++ b/SVSim.Database/Services/Guild/IGuildChatService.cs @@ -1,17 +1,57 @@ +using SVSim.Database.Enums; + namespace SVSim.Database.Services.Guild; public sealed record ChatWindow(IReadOnlyList Messages, int WaitIntervalSeconds); public sealed record ChatPostResult(bool Ok, int? AssignedMessageId); +/// +/// A single entry in the deck log: the raw JSON stored in DeckPayload, plus the envelope fields +/// (messageId, authorViewerId) needed to compute delete_permission_exists in the controller. +/// +public sealed record DeckLogEntry(int MessageId, long AuthorViewerId, string PayloadJson); + +/// +/// Result for /guild_chat/deck_log and /guild_chat/delete_deck. +/// All active (non-deleted) DECK messages in the guild — caller is responsible for grouping by +/// deck_format and computing delete_permission_exists per entry. +/// +public sealed record DeckLogResult(IReadOnlyList Entries); + public interface IGuildChatService { Task GetWindowAsync(long viewerId, int startMessageId, int direction, int waitIntervalHint, CancellationToken ct = default); Task PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default); - Task PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default); - Task DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default); - Task PostReplayAsync(long viewerId, string replayPayloadJson, CancellationToken ct = default); + + /// + /// Share a deck snapshot to guild chat. The deck is looked up by (viewerId, format, deckNo) + /// and its current state is stored as the DECK message's DeckPayload JSON. + /// is the API-side wire integer (used verbatim in the stored payload). + /// + Task PostDeckAsync(long viewerId, Format format, int deckFormatApi, int deckNo, CancellationToken ct = default); + + /// + /// Delete a shared deck. Caller must be the author OR a leader/sub-leader. + /// Returns true on success, false if not found / no permission / already cleared. + /// On success also returns the refreshed deck log. + /// + Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default); + + /// + /// Share a replay to guild chat. Stores {"battle_id": N} as the REPLAY message's ReplayPayload. + /// + Task PostReplayAsync(long viewerId, long battleId, CancellationToken ct = default); + + /// + /// Fetch the replay payload for a given REPLAY chat message. Returns null if not found / not a replay. + /// Task GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default); - Task GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default); + + /// + /// Return the guild's full deck log — all active (non-deleted) DECK messages grouped by + /// API-side Format string key. Returns null if caller is not in a guild. + /// + Task GetDeckLogAsync(long viewerId, CancellationToken ct = default); /// Emit a system-event row. Called by IGuildService at create/join/leave/remove/role-change/description-change. Task EmitSystemEventAsync(int guildId, long actorViewerId, Entities.Guild.GuildChatMessageType type, string? body = null, CancellationToken ct = default); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs index 17a2347e..589cbee0 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs @@ -1,6 +1,9 @@ +using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using SVSim.Database.Enums; using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services.Guild; +using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat; @@ -43,7 +46,9 @@ public sealed class GuildChatController : SVSimController MessageType = (int)m.MessageType, CreateTime = new DateTimeOffset(m.CreatedAt, TimeSpan.Zero).ToUnixTimeSeconds(), Message = m.Body, - // deck / replay / room remain null until Task 17 + Deck = m.DeckPayload is not null ? ParseJsonElementOrNull(m.DeckPayload) : null, + Replay = m.ReplayPayload is not null ? ParseJsonElementOrNull(m.ReplayPayload) : null, + Room = m.RoomPayload is not null ? ParseJsonElementOrNull(m.RoomPayload) : null, }).ToList(); // Build deduplicated users[] catalog from message authors @@ -87,22 +92,163 @@ public sealed class GuildChatController : SVSimController } [HttpPost("add_deck")] - public Task> AddDeck([FromBody] GuildChatAddDeckRequest req, CancellationToken ct) - => Task.FromResult>(new EmptyResponse()); + public async Task> AddDeck([FromBody] GuildChatAddDeckRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + Format format; + try { format = FormatExtensions.FromApi(req.DeckFormat); } + catch (ArgumentOutOfRangeException) { return Ok(new { result_code = 2 }); } + + var result = await _chat.PostDeckAsync(viewerId, format, req.DeckFormat, req.DeckNo, ct); + if (!result.Ok) return Ok(new { result_code = 2 }); + return new EmptyResponse(); + } [HttpPost("delete_deck")] - public Task> DeleteDeck([FromBody] GuildChatDeleteDeckRequest req, CancellationToken ct) - => Task.FromResult>(new EmptyResponse()); + public async Task> DeleteDeck([FromBody] GuildChatDeleteDeckRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var (ok, log) = await _chat.DeleteDeckAsync(viewerId, (int)req.MessageId, ct); + if (!ok || log is null) return Ok(new { result_code = 2 }); + + return new GuildChatDeleteDeckResponse + { + MaintenanceCardList = new(), + DeckLog = BuildDeckLogDict(log, viewerId), + }; + } [HttpPost("add_replay")] - public Task> AddReplay([FromBody] GuildChatAddReplayRequest req, CancellationToken ct) - => Task.FromResult>(new EmptyResponse()); + public async Task> AddReplay([FromBody] GuildChatAddReplayRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var result = await _chat.PostReplayAsync(viewerId, req.BattleId, ct); + if (!result.Ok) return Ok(new { result_code = 2 }); + return new EmptyResponse(); + } [HttpPost("replay_detail")] - public Task> ReplayDetail([FromBody] GuildChatReplayDetailRequest req, CancellationToken ct) - => Task.FromResult>(new GuildChatReplayDetailResponse()); + public async Task> ReplayDetail([FromBody] GuildChatReplayDetailRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var payloadJson = await _chat.GetReplayDetailAsync(viewerId, (int)req.MessageId, ct); + if (payloadJson is null) return Ok(new { result_code = 2 }); + + var replayInfo = ParseJsonElementOrNull(payloadJson); + return new GuildChatReplayDetailResponse { ReplayInfo = replayInfo }; + } [HttpPost("deck_log")] - public Task> DeckLog([FromBody] GuildChatDeckLogRequest req, CancellationToken ct) - => Task.FromResult>(new GuildChatDeckLogResponse()); + public async Task> DeckLog([FromBody] GuildChatDeckLogRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var log = await _chat.GetDeckLogAsync(viewerId, ct); + if (log is null) return Ok(new { result_code = 2 }); + + return new GuildChatDeckLogResponse + { + MaintenanceCardList = new(), + DeckLog = BuildDeckLogDict(log, viewerId), + }; + } + + // ── Private helpers ────────────────────────────────────────────────────── + + /// + /// Converts a DeckLogResult into the wire deck_log dictionary. + /// Keys "1", "2", "3" (Rotation/Unlimited/PreRotation) are always present. + /// Keys "4" (Crossover) and "5" (MyRotation) are only included when non-empty. + /// delete_permission_exists is true when the calling viewer is the author (leader override + /// is handled at the delete endpoint — here it's purely author-based for the display flag). + /// + private static Dictionary> BuildDeckLogDict(DeckLogResult log, long callerViewerId) + { + // Always-present buckets. + var dict = new Dictionary> + { + ["1"] = new(), // Rotation + ["2"] = new(), // Unlimited + ["3"] = new(), // PreRotation + }; + + foreach (var entry in log.Entries) + { + DeckLogDataDto? dto = BuildDeckLogDataDto(entry, callerViewerId); + if (dto is null) continue; + + string key = dto.DeckFormat.ToApi().ToString(); + + if (!dict.TryGetValue(key, out var bucket)) + { + // Optional bucket (Crossover=4, MyRotation=5) — create on first use. + bucket = new List(); + dict[key] = bucket; + } + bucket.Add(dto); + } + + return dict; + } + + /// + /// Deserializes a stored DeckPayload JSON into a DeckLogDataDto. + /// Returns null if the JSON is malformed or missing required fields. + /// + private static DeckLogDataDto? BuildDeckLogDataDto(DeckLogEntry entry, long callerViewerId) + { + try + { + using var doc = JsonDocument.Parse(entry.PayloadJson); + var root = doc.RootElement; + + if (!root.TryGetProperty("deck_format", out var fmtEl)) return null; + int deckFormatApi = fmtEl.GetInt32(); + + Format format; + try { format = FormatExtensions.FromApi(deckFormatApi); } + catch (ArgumentOutOfRangeException) { return null; } + + var cardIdArray = new List(); + if (root.TryGetProperty("card_id_array", out var arrEl)) + { + foreach (var el in arrEl.EnumerateArray()) + cardIdArray.Add(el.GetInt64()); + } + + return new DeckLogDataDto + { + DeckFormat = format, + MessageId = entry.MessageId, + DeletePermissionExists = entry.AuthorViewerId == callerViewerId, + DeckNo = root.TryGetProperty("deck_no", out var dno) ? dno.GetInt32() : 0, + DeckName = root.TryGetProperty("deck_name", out var dn) ? dn.GetString() ?? "" : "", + ClassId = root.TryGetProperty("class_id", out var ci) ? ci.GetInt32() : 0, + SleeveId = root.TryGetProperty("sleeve_id", out var si) ? si.GetInt64() : 3_000_011L, + LeaderSkinId = root.TryGetProperty("leader_skin_id", out var ls) ? ls.GetInt32() : 0, + CardIdArray = cardIdArray, + }; + } + catch + { + return null; + } + } + + private static JsonElement? ParseJsonElementOrNull(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + catch + { + return null; + } + } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/DeckLogDataDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/DeckLogDataDto.cs new file mode 100644 index 00000000..3372a6a3 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/DeckLogDataDto.cs @@ -0,0 +1,65 @@ +using MessagePack; +using SVSim.Database.Enums; +using SVSim.EmulatedEntrypoint.Extensions; +using System.Text.Json.Serialization; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; + +/// +/// Wire shape for a single entry in the deck_log bucket array, and for the inline +/// `deck` object on a DECK-type chat message. Matches ChatMessageInfo.DeckLogData +/// shape (Wizard/ChatMessageInfo.cs:DeckLogData). +/// +/// Keys are a strict subset of what DeckData.Initialize() consumes: +/// deck_format, message_id, delete_permission_exists, deck_no, deck_name, +/// class_id, card_id_array, sleeve_id, leader_skin_id. +/// +[MessagePackObject] +public class DeckLogDataDto +{ + /// API-side Format integer (via FormatJsonConverter). + [JsonPropertyName("deck_format"), Key("deck_format")] + [JsonConverter(typeof(FormatJsonConverter))] + public Format DeckFormat { get; set; } + + /// Per-guild monotonic message_id of the DECK chat message that shared this deck. + [JsonPropertyName("message_id"), Key("message_id")] + [JsonConverter(typeof(StringifiedIntConverter))] + public int MessageId { get; set; } + + /// Whether the viewing user may call /guild_chat/delete_deck for this entry. + [JsonPropertyName("delete_permission_exists"), Key("delete_permission_exists")] + public bool DeletePermissionExists { get; set; } + + /// Deck slot number (within the format's personal deck list). + [JsonPropertyName("deck_no"), Key("deck_no")] + [JsonConverter(typeof(StringifiedIntConverter))] + public int DeckNo { get; set; } + + /// Deck name as it was at share-time. + [JsonPropertyName("deck_name"), Key("deck_name")] + public string DeckName { get; set; } = string.Empty; + + /// Class/craft id. + [JsonPropertyName("class_id"), Key("class_id")] + [JsonConverter(typeof(StringifiedIntConverter))] + public int ClassId { get; set; } + + /// + /// Flat card list — card ids repeated by count, as DeckData.ParseCardIdList expects. + /// + [JsonPropertyName("card_id_array"), Key("card_id_array"), + JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public List CardIdArray { get; set; } = new(); + + /// Sleeve id at share-time. + [JsonPropertyName("sleeve_id"), Key("sleeve_id")] + [JsonConverter(typeof(StringifiedLongConverter))] + public long SleeveId { get; set; } + + /// Leader skin id at share-time. + [JsonPropertyName("leader_skin_id"), Key("leader_skin_id")] + [JsonConverter(typeof(StringifiedIntConverter))] + public int LeaderSkinId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeckLogResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeckLogResponse.cs index ce113930..85291ee0 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeckLogResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeckLogResponse.cs @@ -1,14 +1,15 @@ using MessagePack; -using System.Text.Json; using System.Text.Json.Serialization; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat; /// /// Response for POST /guild_chat/deck_log. -/// deck_log keys are stringified API-side Format ints (e.g. "1", "2"). -/// TODO(task-17): replace deck_log with typed DTOs when DeckLogData shape is fully specified. -/// Crossover/MyRotation keys must be OMITTED (not empty arrays) when those formats are disabled. +/// deck_log keys are stringified API-side Format ints (e.g. "1", "2", "3"). +/// Rotation / Unlimited / PreRotation buckets are always present. +/// Crossover / MyRotation keys are included only when non-empty (client uses TryGetValue). /// [MessagePackObject] public class GuildChatDeckLogResponse @@ -19,8 +20,9 @@ public class GuildChatDeckLogResponse /// /// Decks shared in chat, bucketed by API-side Format value (stringified int keys). - /// JsonElement pass-through for now — Task 17 will type the inner DeckLogEntry shape. + /// Keys present: "1" (Rotation), "2" (Unlimited), "3" (PreRotation); optionally "4" (Crossover), "5" (MyRotation). /// - [JsonPropertyName("deck_log"), Key("deck_log")] - public JsonElement? DeckLog { get; set; } + [JsonPropertyName("deck_log"), Key("deck_log"), + JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Dictionary> DeckLog { get; set; } = new(); } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeleteDeckResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeleteDeckResponse.cs index 23067075..75dacd11 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeleteDeckResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatDeleteDeckResponse.cs @@ -1,13 +1,13 @@ using MessagePack; -using System.Text.Json; using System.Text.Json.Serialization; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat; /// /// Response for POST /guild_chat/delete_deck. -/// Returns refreshed deck_log after deletion. -/// TODO(task-17): replace deck_log with typed DeckLogData DTOs when deck-log shape is fully documented. +/// Returns the refreshed deck_log after deletion — same shape as /guild_chat/deck_log. /// [MessagePackObject] public class GuildChatDeleteDeckResponse @@ -17,9 +17,10 @@ public class GuildChatDeleteDeckResponse public List MaintenanceCardList { get; set; } = new(); /// - /// Refreshed deck log keyed by API-side Format int (stringified, e.g. "1", "2"). - /// Using JsonElement for now; Task 17 will type this properly. + /// Refreshed deck log keyed by API-side Format int (stringified, e.g. "1", "2", "3"). + /// Crossover ("4") / MyRotation ("5") included only when non-empty. /// - [JsonPropertyName("deck_log"), Key("deck_log")] - public JsonElement? DeckLog { get; set; } + [JsonPropertyName("deck_log"), Key("deck_log"), + JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public Dictionary> DeckLog { get; set; } = new(); } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatReplayDetailResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatReplayDetailResponse.cs index 7e71e3dd..8453aca4 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatReplayDetailResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/GuildChat/GuildChatReplayDetailResponse.cs @@ -7,13 +7,16 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat; /// /// Response for POST /guild_chat/replay_detail. /// Shape is opaque pending replay-subsystem documentation. -/// TODO(task-17): enumerate fields from Wizard/ReplayDetailInfo.cs when replay subsystem is documented. +/// TODO(post-merge): enumerate fields from Wizard/ReplayDetailInfo.cs when replay subsystem is documented. +/// Using JsonElement pass-through so the stored ReplayPayload JSON is forwarded verbatim. /// [MessagePackObject] public class GuildChatReplayDetailResponse { - // Placeholder — replay detail shape is undocumented until we capture a live trace. - // Using JsonElement pass-through for now. - [JsonPropertyName("_todo"), Key("_todo")] - public JsonElement? Placeholder { get; set; } + /// + /// Opaque replay payload forwarded verbatim from the stored ReplayPayload JSON. + /// TODO(post-merge): type this properly once replay subsystem shape is captured. + /// + [JsonPropertyName("replay_info"), Key("replay_info")] + public JsonElement? ReplayInfo { get; set; } } diff --git a/SVSim.UnitTests/Integration/Guild/GuildChatAttachmentTests.cs b/SVSim.UnitTests/Integration/Guild/GuildChatAttachmentTests.cs new file mode 100644 index 00000000..fe11ad6e --- /dev/null +++ b/SVSim.UnitTests/Integration/Guild/GuildChatAttachmentTests.cs @@ -0,0 +1,416 @@ +using System.Net.Http.Json; +using System.Text.Json; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Enums; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Integration.Guild; + +/// +/// Integration tests for the five guild_chat attachment endpoints: +/// POST /guild_chat/add_deck, /delete_deck, /add_replay, /replay_detail, /deck_log +/// +public class GuildChatAttachmentTests +{ + private const string Vid = "0"; + private const int Sid = 0; + private const string Stk = ""; + + private static object BaseBody() => new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }; + + private static int GetStringifiedInt(JsonElement el, string prop) + => int.Parse(el.GetProperty(prop).GetString()!); + + private static long GetStringifiedLong(JsonElement el, string prop) + => long.Parse(el.GetProperty(prop).GetString()!); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static async Task CreateGuildAsync(HttpClient client, string name) + { + var createResp = await client.PostAsync("/guild/create", + JsonContent.Create(new + { + guild_name = name, activity = 1, join_condition = 1, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + Assert.That(createResp.IsSuccessStatusCode, Is.True, + $"create guild failed: {await createResp.Content.ReadAsStringAsync()}"); + + var infoResp = await client.PostAsync("/guild/info", JsonContent.Create(BaseBody())); + var infoJson = await infoResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(infoJson); + return GetStringifiedInt(doc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id"); + } + + /// + /// Adds a member to the guild using FREE join (join_condition=1). + /// The guild must have been created with join_condition=1 for this to succeed directly. + /// + private static async Task AddMemberToGuildAsync( + SVSimTestFactory factory, long leaderId, long memberId, int guildId, string guildName) + { + // Get the actual guild_id by querying the leader's guild info. + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + var infoResp = await leaderClient.PostAsync("/guild/info", JsonContent.Create(BaseBody())); + var infoBody = await infoResp.Content.ReadAsStringAsync(); + using var infoDoc = JsonDocument.Parse(infoBody); + int actualGuildId = GetStringifiedInt(infoDoc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id"); + + // Member joins directly (FREE guild, join_condition=1). + using var memberClient = factory.CreateAuthenticatedClient(memberId); + var joinResp = await memberClient.PostAsync("/guild/join", + JsonContent.Create(new + { + guild_id = actualGuildId, + from_invite = false, + viewer_id = memberId.ToString(), steam_id = Sid, steam_session_ticket = Stk + })); + var joinBody = await joinResp.Content.ReadAsStringAsync(); + Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"guild/join failed: {joinBody}"); + } + + private static async Task<(bool ok, string body)> AddDeckAsync(HttpClient client, int deckFormat, int deckNo) + { + var resp = await client.PostAsync("/guild_chat/add_deck", + JsonContent.Create(new + { + deck_format = deckFormat, + deck_no = deckNo, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + return (resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync()); + } + + private static async Task<(bool ok, string body)> DeleteDeckAsync(HttpClient client, int deckFormat, long messageId) + { + var resp = await client.PostAsync("/guild_chat/delete_deck", + JsonContent.Create(new + { + deck_format = deckFormat, + message_id = messageId.ToString(), + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + return (resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync()); + } + + private static async Task<(bool ok, string body)> AddReplayAsync(HttpClient client, long battleId) + { + var resp = await client.PostAsync("/guild_chat/add_replay", + JsonContent.Create(new + { + battle_id = battleId, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + return (resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync()); + } + + private static async Task<(bool ok, JsonElement root)> DeckLogAsync(HttpClient client) + { + var resp = await client.PostAsync("/guild_chat/deck_log", JsonContent.Create(BaseBody())); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + return (resp.IsSuccessStatusCode, doc.RootElement.Clone()); + } + + private static async Task<(bool ok, JsonElement root)> ReplayDetailAsync(HttpClient client, long messageId) + { + var resp = await client.PostAsync("/guild_chat/replay_detail", + JsonContent.Create(new + { + message_id = messageId.ToString(), + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + return (resp.IsSuccessStatusCode, doc.RootElement.Clone()); + } + + private static async Task GetLastMessageIdAsync(HttpClient client) + { + var resp = await client.PostAsync("/guild_chat/messages", + JsonContent.Create(new + { + start_message_id = 0, direction = 1, wait_interval = 3, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + var body = await resp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var msgs = doc.RootElement.GetProperty("chat_message"); + long max = 0; + for (int i = 0; i < msgs.GetArrayLength(); i++) + max = Math.Max(max, GetStringifiedLong(msgs[i], "message_id")); + return max; + } + + // ========================================================================= + // Test 1: add_deck — creates DECK row with DeckPayload; appears in deck_log + // ========================================================================= + + [Test] + public async Task AddDeck_creates_Deck_message_and_appears_in_deck_log() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_001UL, "AttLeader1"); + await factory.SeedDeckAsync(leaderId, Format.Rotation, number: 1, name: "MyDeck"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + await CreateGuildAsync(leaderClient, "AttGuild1"); + + var (ok, body) = await AddDeckAsync(leaderClient, deckFormat: 1, deckNo: 1); + Assert.That(ok, Is.True, $"add_deck should return 200; got: {body}"); + + // deck_log should now include an entry in bucket "1" (Rotation) + var (logOk, logRoot) = await DeckLogAsync(leaderClient); + Assert.That(logOk, Is.True, "deck_log request should succeed"); + Assert.That(logRoot.TryGetProperty("deck_log", out var deckLogEl), Is.True, "deck_log must be present"); + Assert.That(deckLogEl.TryGetProperty("1", out var rotBucket), Is.True, "Rotation bucket '1' must exist"); + Assert.That(rotBucket.GetArrayLength(), Is.EqualTo(1), "One entry in Rotation bucket"); + + var entry = rotBucket[0]; + Assert.That(entry.GetProperty("deck_name").GetString(), Is.EqualTo("MyDeck"), "deck_name must match"); + Assert.That(entry.TryGetProperty("message_id", out _), Is.True, "entry must have message_id"); + Assert.That(entry.TryGetProperty("delete_permission_exists", out var dpEl), Is.True); + Assert.That(dpEl.GetBoolean(), Is.True, "Author can delete own deck"); + } + + // ========================================================================= + // Test 2: add_deck — non-member returns error + // ========================================================================= + + [Test] + public async Task AddDeck_non_member_returns_error() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_002UL, "AttLeader2"); + long outsiderId = await factory.SeedViewerAsync(76_561_198_700_000_003UL, "AttOutsider2"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + using var outsiderClient = factory.CreateAuthenticatedClient(outsiderId); + + await factory.SeedDeckAsync(outsiderId, Format.Rotation, 1); + await CreateGuildAsync(leaderClient, "AttGuild2"); + + var (_, body) = await AddDeckAsync(outsiderClient, deckFormat: 1, deckNo: 1); + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True); + Assert.That(rc.GetInt32(), Is.EqualTo(2), "Non-member must get result_code=2"); + } + + // ========================================================================= + // Test 3: delete_deck (author) — clears payload, row stays, deck_log shrinks + // ========================================================================= + + [Test] + public async Task DeleteDeck_by_author_clears_payload_and_refreshes_log() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_004UL, "AttLeader3"); + await factory.SeedDeckAsync(leaderId, Format.Rotation, 1, "DeleteMe"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + await CreateGuildAsync(leaderClient, "AttGuild3"); + + var (addOk, _) = await AddDeckAsync(leaderClient, deckFormat: 1, deckNo: 1); + Assert.That(addOk, Is.True); + + // Get message_id of the DECK row + long messageId = await GetLastMessageIdAsync(leaderClient); + + // Delete + var (delOk, delBody) = await DeleteDeckAsync(leaderClient, deckFormat: 1, messageId); + Assert.That(delOk, Is.True, $"delete_deck should succeed; got: {delBody}"); + using var delDoc = JsonDocument.Parse(delBody); + // Response must have deck_log + maintenance_card_list + Assert.That(delDoc.RootElement.TryGetProperty("deck_log", out var afterLog), Is.True, + "delete_deck must return refreshed deck_log"); + // The Rotation bucket should now be empty + Assert.That(afterLog.TryGetProperty("1", out var rotBucket), Is.True); + Assert.That(rotBucket.GetArrayLength(), Is.EqualTo(0), "Deck should be removed from log after delete"); + } + + // ========================================================================= + // Test 4: delete_deck (leader on someone else's deck) — success + // ========================================================================= + + [Test] + public async Task DeleteDeck_by_leader_on_member_deck_succeeds() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_005UL, "AttLeader4"); + long memberId = await factory.SeedViewerAsync(76_561_198_700_000_006UL, "AttMember4"); + + await factory.SeedDeckAsync(memberId, Format.Rotation, 1, "MemberDeck"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + using var memberClient = factory.CreateAuthenticatedClient(memberId); + + await CreateGuildAsync(leaderClient, "AttGuild4"); + await AddMemberToGuildAsync(factory, leaderId, memberId, 0, "AttGuild4"); + + var (addOk, _) = await AddDeckAsync(memberClient, deckFormat: 1, deckNo: 1); + Assert.That(addOk, Is.True, "Member should be able to add deck"); + + long messageId = await GetLastMessageIdAsync(leaderClient); + + // Leader deletes member's deck + var (delOk, delBody) = await DeleteDeckAsync(leaderClient, deckFormat: 1, messageId); + Assert.That(delOk, Is.True, $"Leader delete should succeed; got: {delBody}"); + using var doc = JsonDocument.Parse(delBody); + Assert.That(doc.RootElement.TryGetProperty("result_code", out _), Is.False, + "Successful delete must NOT return result_code"); + } + + // ========================================================================= + // Test 5: delete_deck (regular member on someone else's deck) — rejected + // ========================================================================= + + [Test] + public async Task DeleteDeck_by_regular_member_on_other_deck_rejected() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_007UL, "AttLeader5"); + long member1Id = await factory.SeedViewerAsync(76_561_198_700_000_008UL, "AttMember5a"); + long member2Id = await factory.SeedViewerAsync(76_561_198_700_000_009UL, "AttMember5b"); + + await factory.SeedDeckAsync(member1Id, Format.Rotation, 1, "Member1Deck"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + using var member1Client = factory.CreateAuthenticatedClient(member1Id); + using var member2Client = factory.CreateAuthenticatedClient(member2Id); + + await CreateGuildAsync(leaderClient, "AttGuild5"); + await AddMemberToGuildAsync(factory, leaderId, member1Id, 0, "AttGuild5"); + await AddMemberToGuildAsync(factory, leaderId, member2Id, 0, "AttGuild5"); + + var (addOk, _) = await AddDeckAsync(member1Client, deckFormat: 1, deckNo: 1); + Assert.That(addOk, Is.True, "Member1 should be able to add deck"); + + long messageId = await GetLastMessageIdAsync(leaderClient); + + // Member2 tries to delete member1's deck — must fail + var (delOk, delBody) = await DeleteDeckAsync(member2Client, deckFormat: 1, messageId); + Assert.That(delOk, Is.True, "HTTP should be 200 even for logic rejection"); + using var doc = JsonDocument.Parse(delBody); + Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True, + "Rejected delete must return result_code"); + Assert.That(rc.GetInt32(), Is.EqualTo(2), "result_code must be 2 for permission denied"); + } + + // ========================================================================= + // Test 6: add_replay — creates REPLAY row; replay_detail returns payload + // ========================================================================= + + [Test] + public async Task AddReplay_creates_replay_message_and_replay_detail_returns_payload() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_010UL, "AttLeader6"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + await CreateGuildAsync(leaderClient, "AttGuild6"); + + const long battleId = 999_888_777L; + var (addOk, addBody) = await AddReplayAsync(leaderClient, battleId); + Assert.That(addOk, Is.True, $"add_replay should return 200; got: {addBody}"); + + // The REPLAY message should appear in the chat feed + var msgResp = await leaderClient.PostAsync("/guild_chat/messages", + JsonContent.Create(new + { + start_message_id = 0, direction = 1, wait_interval = 3, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk + })); + var msgBody = await msgResp.Content.ReadAsStringAsync(); + using var msgDoc = JsonDocument.Parse(msgBody); + var msgs = msgDoc.RootElement.GetProperty("chat_message"); + + bool foundReplay = false; + long replayMsgId = 0; + for (int i = 0; i < msgs.GetArrayLength(); i++) + { + int msgType = GetStringifiedInt(msgs[i], "message_type"); + if (msgType == (int)GuildChatMessageType.Replay) + { + foundReplay = true; + replayMsgId = GetStringifiedLong(msgs[i], "message_id"); + } + } + Assert.That(foundReplay, Is.True, "A Replay-type message must appear in chat"); + Assert.That(replayMsgId, Is.GreaterThan(0), "Replay message must have a valid message_id"); + + // replay_detail should return the stored payload + var (detailOk, detailRoot) = await ReplayDetailAsync(leaderClient, replayMsgId); + Assert.That(detailOk, Is.True, "replay_detail should return 200"); + // Should NOT have result_code (error marker) + Assert.That(detailRoot.TryGetProperty("result_code", out _), Is.False, + "Successful replay_detail must not have result_code"); + } + + // ========================================================================= + // Test 7: deck_log — always-present Rotation/Unlimited/PreRotation buckets + // ========================================================================= + + [Test] + public async Task DeckLog_always_returns_rotation_unlimited_prerotation_buckets() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_011UL, "AttLeader7"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + await CreateGuildAsync(leaderClient, "AttGuild7"); + + var (ok, root) = await DeckLogAsync(leaderClient); + Assert.That(ok, Is.True); + Assert.That(root.TryGetProperty("deck_log", out var deckLogEl), Is.True); + + // Buckets "1", "2", "3" must always be present (even when empty) + Assert.That(deckLogEl.TryGetProperty("1", out var rot), Is.True, "Rotation bucket '1' must always exist"); + Assert.That(deckLogEl.TryGetProperty("2", out var unl), Is.True, "Unlimited bucket '2' must always exist"); + Assert.That(deckLogEl.TryGetProperty("3", out var pre), Is.True, "PreRotation bucket '3' must always exist"); + + Assert.That(rot.GetArrayLength(), Is.EqualTo(0), "Rotation bucket empty for fresh guild"); + Assert.That(unl.GetArrayLength(), Is.EqualTo(0), "Unlimited bucket empty"); + Assert.That(pre.GetArrayLength(), Is.EqualTo(0), "PreRotation bucket empty"); + + // Crossover ("4") and MyRotation ("5") must NOT be present when empty + Assert.That(deckLogEl.TryGetProperty("4", out _), Is.False, "Crossover bucket must be absent when empty"); + Assert.That(deckLogEl.TryGetProperty("5", out _), Is.False, "MyRotation bucket must be absent when empty"); + } + + // ========================================================================= + // Test 8: deck_log — multiple formats go into correct buckets + // ========================================================================= + + [Test] + public async Task DeckLog_multiple_formats_go_into_correct_buckets() + { + using var factory = new SVSimTestFactory(); + long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_012UL, "AttLeader8"); + + await factory.SeedDeckAsync(leaderId, Format.Rotation, number: 1, name: "RotDeck"); + await factory.SeedDeckAsync(leaderId, Format.Unlimited, number: 1, name: "UnlDeck"); + await factory.SeedDeckAsync(leaderId, Format.PreRotation, number: 1, name: "PreDeck"); + + using var leaderClient = factory.CreateAuthenticatedClient(leaderId); + await CreateGuildAsync(leaderClient, "AttGuild8"); + + // Share all three + await AddDeckAsync(leaderClient, deckFormat: 1, deckNo: 1); // Rotation + await AddDeckAsync(leaderClient, deckFormat: 2, deckNo: 1); // Unlimited + await AddDeckAsync(leaderClient, deckFormat: 3, deckNo: 1); // PreRotation + + var (ok, root) = await DeckLogAsync(leaderClient); + Assert.That(ok, Is.True); + var deckLogEl = root.GetProperty("deck_log"); + + Assert.That(deckLogEl.GetProperty("1").GetArrayLength(), Is.EqualTo(1), "Rotation bucket has 1 entry"); + Assert.That(deckLogEl.GetProperty("2").GetArrayLength(), Is.EqualTo(1), "Unlimited bucket has 1 entry"); + Assert.That(deckLogEl.GetProperty("3").GetArrayLength(), Is.EqualTo(1), "PreRotation bucket has 1 entry"); + + // Check deck names + Assert.That(deckLogEl.GetProperty("1")[0].GetProperty("deck_name").GetString(), Is.EqualTo("RotDeck")); + Assert.That(deckLogEl.GetProperty("2")[0].GetProperty("deck_name").GetString(), Is.EqualTo("UnlDeck")); + Assert.That(deckLogEl.GetProperty("3")[0].GetProperty("deck_name").GetString(), Is.EqualTo("PreDeck")); + } +} diff --git a/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs b/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs index 15da6f81..cc8f1702 100644 --- a/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs +++ b/SVSim.UnitTests/Services/Guild/GuildServiceUpdateTests.cs @@ -26,11 +26,11 @@ internal sealed class SpyGuildChatService : IGuildChatService // ---- stub out the rest ----------------------------------------------- public Task GetWindowAsync(long v, int s, int d, int w, CancellationToken ct = default) => throw new NotImplementedException(); public Task PostTextOrStampAsync(long v, int t, string m, CancellationToken ct = default) => throw new NotImplementedException(); - public Task PostDeckAsync(long v, string j, CancellationToken ct = default) => throw new NotImplementedException(); - public Task DeleteDeckAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException(); - public Task PostReplayAsync(long v, string j, CancellationToken ct = default) => throw new NotImplementedException(); + public Task PostDeckAsync(long v, SVSim.Database.Enums.Format f, int fa, int dn, CancellationToken ct = default) => throw new NotImplementedException(); + public Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task PostReplayAsync(long v, long battleId, CancellationToken ct = default) => throw new NotImplementedException(); public Task GetReplayDetailAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetDeckLogAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetDeckLogAsync(long v, CancellationToken ct = default) => throw new NotImplementedException(); } // ---------------------------------------------------------------------------