diff --git a/Cocotte/Modules/Raids/IPlayerInfosRepository.cs b/Cocotte/Modules/Raids/IPlayerInfosRepository.cs new file mode 100644 index 0000000..4f86b93 --- /dev/null +++ b/Cocotte/Modules/Raids/IPlayerInfosRepository.cs @@ -0,0 +1,9 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Cocotte.Modules.Raids; + +public interface IPlayerInfosRepository +{ + bool TryGetPlayerInfo(ulong playerId, [MaybeNullWhen(false)] out PlayerInfo playerInfo); + void UpdatePlayerInfo(PlayerInfo playerInfo); +} \ No newline at end of file diff --git a/Cocotte/Modules/Raids/MemoryPlayerInfosRepository.cs b/Cocotte/Modules/Raids/MemoryPlayerInfosRepository.cs new file mode 100644 index 0000000..3068ada --- /dev/null +++ b/Cocotte/Modules/Raids/MemoryPlayerInfosRepository.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Cocotte.Modules.Raids; + +public class MemoryPlayerInfosRepository : IPlayerInfosRepository +{ + private readonly IDictionary _playerInfos; + + public MemoryPlayerInfosRepository() + { + _playerInfos = new Dictionary(); + } + + public bool TryGetPlayerInfo(ulong playerId, [MaybeNullWhen(false)] out PlayerInfo playerInfo) + { + return _playerInfos.TryGetValue(playerId, out playerInfo); + } + + public void UpdatePlayerInfo(PlayerInfo playerInfo) + { + _playerInfos[playerInfo.Id] = playerInfo; + } +} \ No newline at end of file diff --git a/Cocotte/Modules/Raids/PlayerInfo.cs b/Cocotte/Modules/Raids/PlayerInfo.cs new file mode 100644 index 0000000..3bcb2c3 --- /dev/null +++ b/Cocotte/Modules/Raids/PlayerInfo.cs @@ -0,0 +1,31 @@ +namespace Cocotte.Modules.Raids; + +public class PlayerInfo +{ + public static TimeSpan FcUpdateInterval { get; } = TimeSpan.FromDays(3); + + public ulong Id { get; } + + public uint Fc + { + get => _fc; + set + { + _fc = value; + _lastFcUpdate = DateTime.Today; + } + } + + public bool IsFcUpdateRequired => DateTime.Today - _lastFcUpdate > FcUpdateInterval; + + private uint _fc; + private DateTime _lastFcUpdate; + + public PlayerInfo(ulong id, uint fc) + { + Id = id; + Fc = fc; + + _lastFcUpdate = DateTime.Today; + } +} \ No newline at end of file diff --git a/Cocotte/Modules/Raids/Raid.cs b/Cocotte/Modules/Raids/Raid.cs index 1129b9e..8ecf0b3 100644 --- a/Cocotte/Modules/Raids/Raid.cs +++ b/Cocotte/Modules/Raids/Raid.cs @@ -21,9 +21,14 @@ public class Raid #endif } - public bool AddPlayer(ulong id, string name, PlayerRole role, int fc, bool substitute = false) + public bool AddPlayer(RosterPlayer player) { - return _rosterManager.AddPlayer(new RosterPlayer(id, name, role, fc, substitute)); + return _rosterManager.AddPlayer(player); + } + + public bool UpdatePlayer(RosterPlayer rosterPlayer) + { + return _rosterManager.UpdatePlayer(rosterPlayer); } public RosterPlayer GetPlayer(ulong id) @@ -31,6 +36,11 @@ public class Raid return _rosterManager.GetPlayer(id); } + public bool ContainsPlayer(ulong userId) + { + return _rosterManager.ContainsPlayer(userId); + } + public bool RemovePlayer(ulong id) { return _rosterManager.RemovePlayer(id); diff --git a/Cocotte/Modules/Raids/RaidFormatter.cs b/Cocotte/Modules/Raids/RaidFormatter.cs index 455e231..b53db5c 100644 --- a/Cocotte/Modules/Raids/RaidFormatter.cs +++ b/Cocotte/Modules/Raids/RaidFormatter.cs @@ -13,7 +13,7 @@ public class RaidFormatter _rolesOptions = rolesOptions; } - private string RoleToEmote(PlayerRole role) => role switch + public string RoleToEmote(PlayerRole role) => role switch { PlayerRole.Dps => _rolesOptions.DpsEmote, PlayerRole.Tank => _rolesOptions.TankEmote, @@ -21,7 +21,7 @@ public class RaidFormatter _ => ":question:" }; - public static string FcFormat(int fc) => fc switch + public static string FcFormat(uint fc) => fc switch { < 1_000 => $"{fc}", _ => $"{fc/1000}k" diff --git a/Cocotte/Modules/Raids/RaidModule.cs b/Cocotte/Modules/Raids/RaidModule.cs index 39622b4..32654c9 100644 --- a/Cocotte/Modules/Raids/RaidModule.cs +++ b/Cocotte/Modules/Raids/RaidModule.cs @@ -13,14 +13,19 @@ namespace Cocotte.Modules.Raids; public class RaidModule : InteractionModuleBase { private readonly ILogger _logger; - private readonly IRaidsRepository _raidsRepository; + private readonly IRaidsRepository _raids; + private readonly IPlayerInfosRepository _playerInfos; private readonly RaidFormatter _raidFormatter; + private readonly RaidRegisterManager _registerManager; - public RaidModule(ILogger logger, IRaidsRepository raidsRepository, RaidFormatter raidFormatter) + public RaidModule(ILogger logger, IRaidsRepository raids, IPlayerInfosRepository playerInfos, + RaidFormatter raidFormatter, RaidRegisterManager registerManager) { _logger = logger; - _raidsRepository = raidsRepository; + _raids = raids; + _playerInfos = playerInfos; _raidFormatter = raidFormatter; + _registerManager = registerManager; } [EnabledInDm(false)] @@ -47,12 +52,12 @@ public class RaidModule : InteractionModuleBase _logger.LogInformation("Created new raid with id {RaidId}", raidId); // Calculate date - var date = DateTime.Now.Date; + var date = DateTime.Today; date = date.AddDays(DateTimeUtils.CalculateDayOfWeekOffset(date.DayOfWeek, day)) .Add(timeOnly.ToTimeSpan()); // New raid instance - if (!_raidsRepository.AddNewRaid(raidId, date)) + if (!_raids.AddNewRaid(raidId, date)) { // A raid with this message id already exists, how?? _logger.LogWarning("Tried to create a new raid with already existing id: {RaidId}", raidId); @@ -67,7 +72,7 @@ public class RaidModule : InteractionModuleBase } // Build the raid message - var raid = _raidsRepository[raidId]; + var raid = _raids[raidId]; var components = RaidComponents(raidId); var embed = _raidFormatter.RaidEmbed(raid); @@ -80,9 +85,9 @@ public class RaidModule : InteractionModuleBase } [ComponentInteraction("raid raid_join:*", true)] - public async Task Join(ulong raidId) + public async Task RaidJoin(ulong raidId) { - if (!_raidsRepository.TryGetRaid(raidId, out var raid)) + if (!_raids.TryGetRaid(raidId, out var raid)) { await RespondAsync( ephemeral: true, @@ -92,9 +97,10 @@ public class RaidModule : InteractionModuleBase return; } - // Todo: Ask role, FC, substitute var user = (IGuildUser) Context.User; - if (!raid.AddPlayer(user.Id, user.DisplayName, PlayerRole.Dps, 10000)) + + // Check if player is already registered early + if (raid.ContainsPlayer(user.Id)) { await RespondAsync( ephemeral: true, @@ -103,19 +109,154 @@ public class RaidModule : InteractionModuleBase return; } - _logger.LogInformation("User {User} joined raid {Raid}", Context.User.Username, raid); + // Ask player info + _registerManager.RegisteringPlayers[(raidId, user.Id)] = new RosterPlayer(user.Id, user.DisplayName); - await UpdateRaidRosterEmbed(raid); - await RespondAsync( - ephemeral: true, - embed: EmbedUtils.SuccessEmbed($"Successfully joined the raid as {raid.GetPlayer(user.Id).Role}").Build() - ); + _logger.LogDebug("User {User} is registering for raid {Raid}", user.Username, raid); + + // Add name + await RespondAsync($"Please select a role for raid", components: PlayerRoleComponent(raid, user).Build(), ephemeral: true); + } + + [ComponentInteraction("raid player_select_role:*:*", ignoreGroupNames: true)] + public async Task PlayerSelectRole(ulong raidId, ulong playerId, string selectedRoleInput) + { + var selectedRole = Enum.Parse(selectedRoleInput); + + if (_registerManager.RegisteringPlayers.TryGetValue((raidId, playerId), out var rosterPlayer)) + { + _registerManager.RegisteringPlayers[(raidId, playerId)] = rosterPlayer with {Role = selectedRole}; + + await RespondAsync(); + } + // The user is not currently registering, wonder how he got here then + else + { + await RespondAsync(ephemeral: true, + embed: EmbedUtils.ErrorEmbed("You are not registering for this raid :thinking:").Build()); + } + } + + [ComponentInteraction("raid player_join:*:*", ignoreGroupNames: true)] + public async Task PlayerJoinNonSubstitute(ulong raidId, ulong playerId) + { + await PlayerJoin(raidId, playerId, false); + } + + [ComponentInteraction("raid player_join_substitute:*:*", ignoreGroupNames: true)] + public async Task PlayerJoinSubstitute(ulong raidId, ulong playerId) + { + await PlayerJoin(raidId, playerId, true); + } + + private async Task PlayerJoin(ulong raidId, ulong playerId, bool substitute) + { + // Check if player is registering + if (!_registerManager.RegisteringPlayers.TryGetValue((raidId, playerId), out var rosterPlayer)) + { + await RespondAsync(ephemeral: true, + embed: EmbedUtils.ErrorEmbed("You are not registering for this raid :thinking:").Build()); + + return; + } + + // Check if we need to ask FC + if (!_playerInfos.TryGetPlayerInfo(playerId, out var playerInfo)) + { + await Context.Interaction.RespondWithModalAsync($"raid modal_fc:{raidId}"); + + return; + } + + // Player already has FC registered but it's outdated + if (playerInfo.IsFcUpdateRequired) + { + await Context.Interaction.RespondWithModalAsync($"raid modal_fc:{raidId}", + modifyModal: m => + m.UpdateTextInput("fc", component => component.Value = playerInfo.Fc.FormatSpaced()) + ); + + return; + } + + // Register user for raid + await RegisterPlayer(raidId, rosterPlayer with { Fc = rosterPlayer.Fc, Substitute = substitute}); + } + + [ModalInteraction("raid modal_fc:*", ignoreGroupNames: true)] + public async Task ModalFcSubmit(ulong raidId, FcModal modal) + { + var playerId = Context.User.Id; + _logger.LogTrace("Received modal FC modal from {User} with value: {Fc}", Context.User.Username, modal.Fc); + + // Check if player is registering + if (!_registerManager.RegisteringPlayers.TryGetValue((raidId, playerId), out var rosterPlayer)) + { + await RespondAsync(ephemeral: true, + embed: EmbedUtils.ErrorEmbed("You are not registering for this raid :thinking:").Build()); + + return; + } + + var fcInput = modal.Fc.Replace(" ", ""); + + if (!uint.TryParse(fcInput, out var fc)) + { + await RespondAsync(ephemeral: true, + embed: EmbedUtils.ErrorEmbed("Invalid fc, try registering again").Build()); + + _registerManager.RegisteringPlayers.Remove((raidId, playerId)); + + return; + } + + _playerInfos.UpdatePlayerInfo(new PlayerInfo(playerId, fc)); + + await RegisterPlayer(raidId, rosterPlayer with { Fc = fc }); + } + + private async Task RegisterPlayer(ulong raidId, RosterPlayer rosterPlayer) + { + // Check if raid exists + if (!_raids.TryGetRaid(raidId, out var raid)) + { + await RespondAsync( + ephemeral: true, + embed: EmbedUtils.InfoEmbed("This raid does not exist").Build()); + + return; + } + + _registerManager.RegisteringPlayers[(raidId, Context.User.Id)] = rosterPlayer; + + // Player is already registered, update info + if (!raid.AddPlayer(rosterPlayer)) + { + raid.UpdatePlayer(rosterPlayer); + + await UpdateRaidRosterEmbed(raid); + // await RespondAsync( + // ephemeral: true, + // embed: EmbedUtils.SuccessEmbed($"Successfully update you're role to: {rosterPlayer.Role}").Build()); + await RespondAsync(); + } + // It's a new player + else + { + _logger.LogInformation("User {User} joined raid {Raid}", Context.User.Username, raid); + + await UpdateRaidRosterEmbed(raid); + await RespondAsync( + ephemeral: true, + embed: EmbedUtils.SuccessEmbed($"Successfully joined the raid as {raid.GetPlayer(rosterPlayer.Id).Role}").Build() + ); + } } [ComponentInteraction("raid raid_leave:*", ignoreGroupNames: true)] public async Task Leave(ulong raidId) { - if (!_raidsRepository.TryGetRaid(raidId, out var raid)) + if (!_raids.TryGetRaid(raidId, out var raid)) { await RespondAsync( ephemeral: true, @@ -155,7 +296,7 @@ public class RaidModule : InteractionModuleBase } } - private ComponentBuilder RaidComponents(ulong raidId) + private static ComponentBuilder RaidComponents(ulong raidId) { return new ComponentBuilder() .AddRow(new ActionRowBuilder() @@ -171,4 +312,38 @@ public class RaidModule : InteractionModuleBase ) ); } + + private ComponentBuilder PlayerRoleComponent(Raid raid, IGuildUser user) + { + var select = new SelectMenuBuilder() + .WithPlaceholder(PlayerRole.Dps.ToString()) + .WithCustomId($"raid player_select_role:{raid.Id}:{user.Id}") + .WithMinValues(1) + .WithMaxValues(1); + + foreach (var role in Enum.GetValues()) + { + // TODO add emote + select.AddOption(role.ToString(), role.ToString()); + } + + return new ComponentBuilder() + .AddRow(new ActionRowBuilder() + .WithSelectMenu(select) + ) + .AddRow(new ActionRowBuilder() + .WithButton("Join", $"raid player_join:{raid.Id}:{user.Id}") + .WithButton("Join substitute", $"raid player_join_substitute:{raid.Id}:{user.Id}") + ); + } +} + +public class FcModal : IModal +{ + public string Title => "Please enter your FC"; + + [NotNull] + [InputLabel("FC")] + [ModalTextInput("fc", placeholder: "30 000", minLength: 1, maxLength: 7)] + public string? Fc { get; set; } } \ No newline at end of file diff --git a/Cocotte/Modules/Raids/RaidRegisterManager.cs b/Cocotte/Modules/Raids/RaidRegisterManager.cs new file mode 100644 index 0000000..26db65f --- /dev/null +++ b/Cocotte/Modules/Raids/RaidRegisterManager.cs @@ -0,0 +1,7 @@ +namespace Cocotte.Modules.Raids; + +public class RaidRegisterManager +{ + public IDictionary<(ulong raidId, ulong playerId), RosterPlayer> RegisteringPlayers = + new Dictionary<(ulong raidId, ulong playerId), RosterPlayer>(); +} \ No newline at end of file diff --git a/Cocotte/Modules/Raids/RosterManager.cs b/Cocotte/Modules/Raids/RosterManager.cs index af8643b..55dac29 100644 --- a/Cocotte/Modules/Raids/RosterManager.cs +++ b/Cocotte/Modules/Raids/RosterManager.cs @@ -14,6 +14,18 @@ public class RosterManager return _players.TryAdd(rosterPlayer.Id, rosterPlayer); } + public bool UpdatePlayer(RosterPlayer rosterPlayer) + { + if (!_players.ContainsKey(rosterPlayer.Id)) + { + return false; + } + + _players[rosterPlayer.Id] = rosterPlayer; + + return true; + } + public bool RemovePlayer(ulong id) { return _players.Remove(id); @@ -23,4 +35,9 @@ public class RosterManager { return _players[id]; } + + public bool ContainsPlayer(ulong userId) + { + return _players.ContainsKey(userId); + } } \ No newline at end of file diff --git a/Cocotte/Modules/Raids/RosterPlayer.cs b/Cocotte/Modules/Raids/RosterPlayer.cs index 4fa53c8..cb1ce1b 100644 --- a/Cocotte/Modules/Raids/RosterPlayer.cs +++ b/Cocotte/Modules/Raids/RosterPlayer.cs @@ -1,6 +1,6 @@ namespace Cocotte.Modules.Raids; -public record RosterPlayer(ulong Id, string Name, PlayerRole Role, int Fc, bool Substitute = false) +public record RosterPlayer(ulong Id, string Name, PlayerRole Role = PlayerRole.Dps, uint Fc = 0, bool Substitute = false) { public int RosterNumber { get; set; } diff --git a/Cocotte/Program.cs b/Cocotte/Program.cs index 479130f..7886dad 100644 --- a/Cocotte/Program.cs +++ b/Cocotte/Program.cs @@ -34,10 +34,12 @@ IHost host = Host.CreateDefaultBuilder(args) // Data services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Raids services.AddSingleton(); + services.AddSingleton(); // Custom services.AddSingleton(); diff --git a/Cocotte/Utils/ModalExtensions.cs b/Cocotte/Utils/ModalExtensions.cs new file mode 100644 index 0000000..a71a0b2 --- /dev/null +++ b/Cocotte/Utils/ModalExtensions.cs @@ -0,0 +1,34 @@ +using Discord; + +namespace Cocotte.Utils; + +public static class ModalExtensions +{ + public static ModalBuilder UpdateTextInput(this ModalBuilder modal, string customId, Action inputUpdater) + { + var components = modal.Components.ActionRows.SelectMany(r => r.Components).OfType(); + var component = components.First(c => c.CustomId == customId); + + var builder = new TextInputBuilder + { + CustomId = customId, + Label = component.Label, + MaxLength = component.MaxLength, + MinLength = component.MinLength, + Placeholder = component.Placeholder, + Required = component.Required, + Style = component.Style, + Value = component.Value + }; + + inputUpdater(builder); + + foreach (var row in modal.Components.ActionRows.Where(row => row.Components.Any(c => c.CustomId == customId))) + { + row.Components.RemoveAll(c => c.CustomId == customId); + row.AddComponent(builder.Build()); + } + + return modal; + } +} \ No newline at end of file diff --git a/Cocotte/Utils/NumberUtils.cs b/Cocotte/Utils/NumberUtils.cs new file mode 100644 index 0000000..feab3e1 --- /dev/null +++ b/Cocotte/Utils/NumberUtils.cs @@ -0,0 +1,38 @@ +using System.Numerics; +using System.Text; + +namespace Cocotte.Utils; + +public static class NumberUtils +{ + public static string FormatSpaced(this INumber number) where T : INumber? + { + var stringNumber = number.ToString(null, null); + + Span result = stackalloc char[stringNumber.Length + stringNumber.Length / 3]; + int resultOffset = 0; + + for (int i = 0; i < stringNumber.Length; i++) + { + // Add a space + if (i > 2 && (i + 1) % 3 == 1) + { + result[resultOffset] = ' '; + resultOffset++; + } + + result[resultOffset] = stringNumber[^(i + 1)]; + resultOffset++; + } + + var realResult = result[..resultOffset]; + Span reversed = stackalloc char[resultOffset]; + + for (int i = 0; i < reversed.Length; i++) + { + reversed[i] = realResult[^(i + 1)]; + } + + return reversed.ToString(); + } +} \ No newline at end of file diff --git a/Cocotte/Utils/RaidExtensions.cs b/Cocotte/Utils/RaidExtensions.cs index 97a19ca..b70b92a 100644 --- a/Cocotte/Utils/RaidExtensions.cs +++ b/Cocotte/Utils/RaidExtensions.cs @@ -6,9 +6,9 @@ public static class RaidExtensions { public static void AddTestPlayers(this Raid raid) { - raid.AddPlayer(0, "YamaRaja", PlayerRole.Healer, 30000, false); - raid.AddPlayer(1, "Zaku", PlayerRole.Dps, 40000, false); - raid.AddPlayer(2, "Juchi", PlayerRole.Tank, 40000, false); - raid.AddPlayer(3, "Akeno", PlayerRole.Dps, 40000, true); + raid.AddPlayer(new RosterPlayer(0, "YamaRaja", PlayerRole.Healer, 30000, false)); + raid.AddPlayer(new RosterPlayer(1, "Zaku", PlayerRole.Dps, 40000, false)); + raid.AddPlayer(new RosterPlayer(2, "Juchi", PlayerRole.Tank, 40000, false)); + raid.AddPlayer(new RosterPlayer(3, "Akeno", PlayerRole.Dps, 40000, true)); } } \ No newline at end of file