feat(guild_chat): deck + replay attachments

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 15:14:06 -04:00
parent ab1e23b7cb
commit eb65c2081c
11 changed files with 846 additions and 54 deletions

View File

@@ -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<ChatPostResult> PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default)
=> throw new NotImplementedException();
// ── Deck attachment ──────────────────────────────────────────────────────
public Task<bool> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default)
=> throw new NotImplementedException();
public async Task<ChatPostResult> 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<ChatPostResult> 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<string?> 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<string?> 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<ChatPostResult> 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<string?> 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<DeckLogResult?> 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<DeckLogResult> 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);
}
}