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:
@@ -150,13 +150,25 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default)
|
||||
public Task<GuildChatMessage?> GetByMessageIdAsync(int guildId, int messageId, CancellationToken ct = default)
|
||||
=> _db.GuildChatMessages
|
||||
.FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildChatMessage>> 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<bool> 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;
|
||||
|
||||
@@ -14,7 +14,18 @@ public interface IGuildChatMessageRepository
|
||||
Task<int> GetMaxMessageIdAsync(int guildId, CancellationToken ct = default);
|
||||
Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default);
|
||||
/// <summary>Fetch a single message by (guildId, messageId). Returns null if not found.</summary>
|
||||
Task<GuildChatMessage?> GetByMessageIdAsync(int guildId, int messageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch all DECK-type messages for a guild (non-null DeckPayload only — clears are excluded).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GuildChatMessage>> GetDeckMessagesAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <paramref name="leaderOverride"/> bypasses the author check — pass true when caller is Leader or SubLeader.
|
||||
/// </summary>
|
||||
Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, bool leaderOverride, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Services.Guild;
|
||||
|
||||
public sealed record ChatWindow(IReadOnlyList<Entities.Guild.GuildChatMessage> Messages, int WaitIntervalSeconds);
|
||||
public sealed record ChatPostResult(bool Ok, int? AssignedMessageId);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record DeckLogEntry(int MessageId, long AuthorViewerId, string PayloadJson);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record DeckLogResult(IReadOnlyList<DeckLogEntry> Entries);
|
||||
|
||||
public interface IGuildChatService
|
||||
{
|
||||
Task<ChatWindow> GetWindowAsync(long viewerId, int startMessageId, int direction, int waitIntervalHint, CancellationToken ct = default);
|
||||
Task<ChatPostResult> PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default);
|
||||
Task<ChatPostResult> PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default);
|
||||
Task<bool> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
Task<ChatPostResult> PostReplayAsync(long viewerId, string replayPayloadJson, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// <paramref name="deckFormatApi"/> is the API-side wire integer (used verbatim in the stored payload).
|
||||
/// </summary>
|
||||
Task<ChatPostResult> PostDeckAsync(long viewerId, Format format, int deckFormatApi, int deckNo, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Share a replay to guild chat. Stores {"battle_id": N} as the REPLAY message's ReplayPayload.
|
||||
/// </summary>
|
||||
Task<ChatPostResult> PostReplayAsync(long viewerId, long battleId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the replay payload for a given REPLAY chat message. Returns null if not found / not a replay.
|
||||
/// </summary>
|
||||
Task<string?> GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
Task<string?> GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<DeckLogResult?> GetDeckLogAsync(long viewerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Emit a system-event row. Called by IGuildService at create/join/leave/remove/role-change/description-change.</summary>
|
||||
Task EmitSystemEventAsync(int guildId, long actorViewerId, Entities.Guild.GuildChatMessageType type, string? body = null, CancellationToken ct = default);
|
||||
|
||||
Reference in New Issue
Block a user