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");
}