diff --git a/SVSim.Database/Services/Guild/GuildChatService.cs b/SVSim.Database/Services/Guild/GuildChatService.cs new file mode 100644 index 00000000..4e96decf --- /dev/null +++ b/SVSim.Database/Services/Guild/GuildChatService.cs @@ -0,0 +1,55 @@ +using SVSim.Database.Repositories.Guild; +using SVSim.Database.Services; + +namespace SVSim.Database.Services.Guild; + +public sealed class GuildChatService : IGuildChatService +{ + private readonly IGuildRepository _guilds; + private readonly IGuildMemberRepository _members; + private readonly IGuildInviteRepository _invites; + private readonly IGuildJoinRequestRepository _joinRequests; + private readonly IGuildChatMessageRepository _chatMessages; + private readonly IGameConfigService _config; + + public GuildChatService( + IGuildRepository guilds, + IGuildMemberRepository members, + IGuildInviteRepository invites, + IGuildJoinRequestRepository joinRequests, + IGuildChatMessageRepository chatMessages, + IGameConfigService config) + { + _guilds = guilds; + _members = members; + _invites = invites; + _joinRequests = joinRequests; + _chatMessages = chatMessages; + _config = config; + } + + public Task GetWindowAsync(long viewerId, int startMessageId, int direction, int waitIntervalHint, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task PostReplayAsync(long viewerId, string replayPayloadJson, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default) + => throw new NotImplementedException(); + + // No-op skeleton — will be wired in Phase 5. + public Task EmitSystemEventAsync(int guildId, long actorViewerId, Entities.Guild.GuildChatMessageType type, string? body = null, CancellationToken ct = default) + => Task.CompletedTask; +} diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs new file mode 100644 index 00000000..b6cc2b93 --- /dev/null +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -0,0 +1,91 @@ +using SVSim.Database.Repositories.Guild; +using SVSim.Database.Services; + +namespace SVSim.Database.Services.Guild; + +public sealed class GuildService : IGuildService +{ + private readonly IGuildRepository _guilds; + private readonly IGuildMemberRepository _members; + private readonly IGuildInviteRepository _invites; + private readonly IGuildJoinRequestRepository _joinRequests; + private readonly IGuildChatMessageRepository _chatMessages; + private readonly IGameConfigService _config; + + public GuildService( + IGuildRepository guilds, + IGuildMemberRepository members, + IGuildInviteRepository invites, + IGuildJoinRequestRepository joinRequests, + IGuildChatMessageRepository chatMessages, + IGameConfigService config) + { + _guilds = guilds; + _members = members; + _invites = invites; + _joinRequests = joinRequests; + _chatMessages = chatMessages; + _config = config; + } + + // Returns null so /guild/info can short-circuit to a non-joined response in Phase 1. + public Task GetMyGuildAsync(long viewerId, CancellationToken ct = default) + => Task.FromResult(null); + + public Task GetActiveAsync(int guildId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task UpdateDescriptionAsync(long viewerId, string description, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task BreakupAsync(long viewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CancelInviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task RejectInviteAsync(long callerViewerId, int guildId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task JoinAsync(long viewerId, int guildId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task LeaveAsync(long viewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default) + => throw new NotImplementedException(); +} diff --git a/SVSim.Database/Services/Guild/GuildServiceTypes.cs b/SVSim.Database/Services/Guild/GuildServiceTypes.cs new file mode 100644 index 00000000..04ac0083 --- /dev/null +++ b/SVSim.Database/Services/Guild/GuildServiceTypes.cs @@ -0,0 +1,40 @@ +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Services.Guild; + +public enum GuildOpResultCode +{ + Ok = 0, + NotInGuild, + AlreadyInGuild, + GuildNotFound, + NameTaken, + NameInvalid, + MemberCapReached, + SubLeaderCapReached, + PermissionDenied, + InviteNotFound, + InviteAlreadyResolved, + JoinRequestNotFound, + JoinRequestAlreadyResolved, + TargetNotInGuild, + LeaderLeaveBlocked, // leader cannot leave while other members remain + InvalidRoleTransition, +} + +public sealed record GuildOpResult(GuildOpResultCode Code, int? GuildId = null, string? Detail = null) +{ + public static readonly GuildOpResult Ok = new(GuildOpResultCode.Ok); + public bool IsOk => Code == GuildOpResultCode.Ok; +} + +public sealed record GuildFullView( + Entities.Guild.Guild Guild, + IReadOnlyList Members, + int JoinRequestCount, + int InviteCount); + +public sealed record GuildSearchEntry(Entities.Guild.Guild Guild, int MemberNum); + +public sealed record CreateGuildRequest(string Name, int Activity, int JoinCondition); +public sealed record UpdateGuildRequest(int? Activity, int? JoinCondition); diff --git a/SVSim.Database/Services/Guild/IGuildChatService.cs b/SVSim.Database/Services/Guild/IGuildChatService.cs new file mode 100644 index 00000000..1f15c2a4 --- /dev/null +++ b/SVSim.Database/Services/Guild/IGuildChatService.cs @@ -0,0 +1,18 @@ +namespace SVSim.Database.Services.Guild; + +public sealed record ChatWindow(IReadOnlyList Messages, int WaitIntervalSeconds); +public sealed record ChatPostResult(bool Ok, int? AssignedMessageId); + +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); + Task GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default); + Task GetDeckLogAsync(long viewerId, int messageId, 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.Database/Services/Guild/IGuildService.cs b/SVSim.Database/Services/Guild/IGuildService.cs new file mode 100644 index 00000000..0c53b38e --- /dev/null +++ b/SVSim.Database/Services/Guild/IGuildService.cs @@ -0,0 +1,40 @@ +namespace SVSim.Database.Services.Guild; + +/// +/// Single dispatch surface for guild business operations. Enforces: +/// - one viewer ≤1 guild +/// - MaxMemberNum / MaxSubLeaderNum caps (from GuildConfig) +/// - role-based permissions per docs/superpowers/specs/2026-06-27-guild-functionality-design.md +/// - leader-leaves-with-members blocked +/// - auto-routing single-member leader leave -> breakup +/// - clear pending invites + cancel pending join_requests on successful join +/// - emit system chat events via IGuildChatService +/// +public interface IGuildService +{ + Task GetMyGuildAsync(long viewerId, CancellationToken ct = default); + Task GetActiveAsync(int guildId, CancellationToken ct = default); + Task> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default); + + Task CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default); + Task UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default); + Task UpdateDescriptionAsync(long viewerId, string description, CancellationToken ct = default); + Task UpdateEmblemAsync(long viewerId, long emblemId, CancellationToken ct = default); + Task BreakupAsync(long viewerId, CancellationToken ct = default); + + Task InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default); + Task CancelInviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default); + Task RejectInviteAsync(long callerViewerId, int guildId, CancellationToken ct = default); + + Task JoinAsync(long viewerId, int guildId, CancellationToken ct = default); + Task CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default); + Task AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default); + Task RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default); + + Task LeaveAsync(long viewerId, CancellationToken ct = default); + Task RemoveAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default); + Task ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default); + + Task> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default); + Task> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 4c635f8b..fbc55a3b 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -121,6 +121,14 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped();