From a248c167e044dce58bfc948fe0012a3f852f89e1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 27 Jun 2026 14:51:44 -0400 Subject: [PATCH] refactor(guild_chat): extract chat-user lookup to viewer repo; fix NEW-direction exclusivity Add ChatUserProfile record and IViewerRepository.LoadChatProfilesAsync (AsNoTracking, projects Name/EmblemId/CountryCode/Rank/DegreeId from Viewer+Info nav refs). GuildChatController.Messages now injects IViewerRepository instead of SVSimDbContext; calls LoadChatProfilesAsync to populate users[] (removes direct db access from controller). GuildChatMessageRepository direction=2 (NEW): change >= start to > start (exclusive). GuildChatPollTests updated to assert the start message is NOT returned by direction=NEW. Co-Authored-By: Claude Sonnet 4.6 --- .../Guild/GuildChatMessageRepository.cs | 4 +-- .../Repositories/Viewer/IViewerRepository.cs | 18 +++++++++++ .../Repositories/Viewer/ViewerRepository.cs | 27 ++++++++++++++++ .../Controllers/GuildChatController.cs | 32 +++++++------------ .../Integration/Guild/GuildChatPollTests.cs | 22 +++++++++---- 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs index 5b427a3e..1338baf3 100644 --- a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs +++ b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs @@ -102,9 +102,9 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository .OrderBy(m => m.MessageId) .ToListAsync(ct); - case 2: // NEW — messages newer than (and including) start + case 2: // NEW: strictly newer than start (exclusive) return await baseQ - .Where(m => m.MessageId >= start) + .Where(m => m.MessageId > start) .OrderBy(m => m.MessageId) .Take(limit) .ToListAsync(ct); diff --git a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs index 492939ef..7c186028 100644 --- a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs @@ -2,6 +2,17 @@ using SVSim.Database.Enums; namespace SVSim.Database.Repositories.Viewer; +/// +/// Lightweight profile shape used by chat surfaces (guild_chat, gathering_chat). +/// Fields match the ChatUserDto wire shape. +/// +public record ChatUserProfile( + string Name, + long EmblemId, + string CountryCode, + int Rank, + int DegreeId); + public interface IViewerRepository { Task GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId); @@ -39,4 +50,11 @@ public interface IViewerRepository /// ids with no matching row are absent from the result. Read-only (AsNoTracking). /// Task> LoadDisplayNamesAsync(IReadOnlyCollection viewerIds, CancellationToken ct = default); + + /// + /// Batch-loads the fields for a set of viewer ids. Used by chat + /// surfaces (guild_chat, gathering_chat) to populate users[] without a direct DB context + /// in the controller. Ids with no matching row are absent from the result. Read-only (AsNoTracking). + /// + Task> LoadChatProfilesAsync(IReadOnlyCollection viewerIds, CancellationToken ct = default); } diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index fb0feb55..44731c5b 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -296,6 +296,33 @@ public class ViewerRepository : IViewerRepository .ToDictionaryAsync(v => v.Id, v => v.DisplayName, ct); } + public async Task> LoadChatProfilesAsync(IReadOnlyCollection viewerIds, CancellationToken ct = default) + { + if (viewerIds.Count == 0) return new Dictionary(); + var rows = await _dbContext.Set() + .AsNoTracking() + .Include(v => v.Info.SelectedEmblem) + .Include(v => v.Info.SelectedDegree) + .Where(v => viewerIds.Contains(v.Id)) + .Select(v => new + { + v.Id, + v.DisplayName, + EmblemId = v.Info.SelectedEmblem != null ? v.Info.SelectedEmblem.Id : 100_000_000L, + CountryCode = v.Info.CountryCode ?? "", + DegreeId = v.Info.SelectedDegree != null ? (int)v.Info.SelectedDegree.Id : 0, + }) + .ToListAsync(ct); + return rows.ToDictionary( + r => r.Id, + r => new ChatUserProfile( + Name: r.DisplayName, + EmblemId: r.EmblemId, + CountryCode: r.CountryCode, + Rank: 1, // TODO: real rank when rank tracking lands + DegreeId: r.DegreeId)); + } + private async Task BuildDefaultViewer(string displayName, int initialTutorialState = 1) { Models.Viewer viewer = new Models.Viewer diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs index 5043f904..794e7ee7 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildChatController.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SVSim.Database; +using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; @@ -14,12 +13,12 @@ namespace SVSim.EmulatedEntrypoint.Controllers; public sealed class GuildChatController : SVSimController { private readonly IGuildChatService _chat; - private readonly SVSimDbContext _db; + private readonly IViewerRepository _viewers; - public GuildChatController(IGuildChatService chat, SVSimDbContext db) + public GuildChatController(IGuildChatService chat, IViewerRepository viewers) { - _chat = chat; - _db = db; + _chat = chat; + _viewers = viewers; } [HttpPost("messages")] @@ -53,26 +52,19 @@ public sealed class GuildChatController : SVSimController .Distinct() .ToList(); - var viewers = authorIds.Count == 0 - ? new Dictionary() - : await _db.Viewers - .AsNoTracking() - .Include(v => v.Info.SelectedEmblem) - .Include(v => v.Info.SelectedDegree) - .Where(v => authorIds.Contains(v.Id)) - .ToDictionaryAsync(v => v.Id, ct); + var profiles = await _viewers.LoadChatProfilesAsync(authorIds, ct); var users = authorIds.Select(vid => { - viewers.TryGetValue(vid, out var v); + profiles.TryGetValue(vid, out var p); return new ChatUserDto { ViewerId = vid, - Name = v?.DisplayName ?? "", - EmblemId = v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L, - CountryCode = v?.Info?.CountryCode ?? "", - Rank = 1, // TODO: real rank when rank tracking lands - DegreeId = v?.Info?.SelectedDegree?.Id ?? 0, + Name = p?.Name ?? "", + EmblemId = p?.EmblemId ?? 100_000_000L, + CountryCode = p?.CountryCode ?? "", + Rank = p?.Rank ?? 1, + DegreeId = p?.DegreeId ?? 0, }; }).ToList(); diff --git a/SVSim.UnitTests/Integration/Guild/GuildChatPollTests.cs b/SVSim.UnitTests/Integration/Guild/GuildChatPollTests.cs index 9aad4adc..b94ea242 100644 --- a/SVSim.UnitTests/Integration/Guild/GuildChatPollTests.cs +++ b/SVSim.UnitTests/Integration/Guild/GuildChatPollTests.cs @@ -193,24 +193,32 @@ public class GuildChatPollTests viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); - // Poll NEW from createGuildMsgId — should return only the Join message + // Poll NEW from createGuildMsgId — should return only the Join message (exclusive: > start) var newPoll = await PollAsync(leaderClient, startMessageId: createGuildMsgId, direction: 2 /* NEW */); var newMsgs = newPoll.GetProperty("chat_message"); - // direction=NEW returns messages with message_id >= start, but since we want > start - // the repository uses ">= start" for NEW. In practice the first NEW msg returned includes - // the start message itself if it exists, plus newer ones. Let's verify Join is present. + // direction=NEW uses strict > (exclusive): the CreateGuild message at message_id=createGuildMsgId + // must NOT appear; only messages with message_id strictly greater than start are returned. + bool hasCreateGuild = false; bool hasJoin = false; for (int i = 0; i < newMsgs.GetArrayLength(); i++) { - if (GetStringifiedInt(newMsgs[i], "message_type") == (int)GuildChatMessageType.Join) + int msgType = GetStringifiedInt(newMsgs[i], "message_type"); + long msgId = GetStringifiedLong(newMsgs[i], "message_id"); + if (msgType == (int)GuildChatMessageType.CreateGuild) + hasCreateGuild = true; + if (msgType == (int)GuildChatMessageType.Join) { hasJoin = true; - // Its message_id must be > createGuildMsgId - Assert.That(GetStringifiedLong(newMsgs[i], "message_id"), Is.GreaterThan(createGuildMsgId), + Assert.That(msgId, Is.GreaterThan(createGuildMsgId), "Join message_id must be greater than the CreateGuild id"); } + // Every returned message must have message_id strictly > start (exclusive semantics) + Assert.That(msgId, Is.GreaterThan(createGuildMsgId), + $"direction=NEW must be exclusive: message_id {msgId} must be > start {createGuildMsgId}"); } + Assert.That(hasCreateGuild, Is.False, + "direction=NEW (exclusive) must NOT include the message at start itself"); Assert.That(hasJoin, Is.True, "direction=NEW poll must include the Join message"); }