diff --git a/Cocotte/Cocotte.csproj b/Cocotte/Cocotte.csproj
index 347f228..55e9e2a 100644
--- a/Cocotte/Cocotte.csproj
+++ b/Cocotte/Cocotte.csproj
@@ -11,4 +11,8 @@
+
+
+
+
diff --git a/Cocotte/Modules/PingModule.cs b/Cocotte/Modules/Ping/PingModule.cs
similarity index 99%
rename from Cocotte/Modules/PingModule.cs
rename to Cocotte/Modules/Ping/PingModule.cs
index a52f8c7..2f82eaf 100644
--- a/Cocotte/Modules/PingModule.cs
+++ b/Cocotte/Modules/Ping/PingModule.cs
@@ -8,12 +8,13 @@ using Discord;
using Discord.Interactions;
using Discord.WebSocket;
-namespace Cocotte.Modules;
+namespace Cocotte.Modules.Ping;
///
/// Module containing different test and debug commands
///
[RequireOwner]
+[Group("ping", "Debug related commands")]
public class PingModule : InteractionModuleBase
{
private readonly ILogger _logger;
@@ -291,7 +292,7 @@ public class FoodModal : IModal
[ModalTextInput("food_name", placeholder: "Pizza", maxLength: 20)]
public string? Food { get; set; }
- // Additional paremeters can be specified to further customize the input.
+ // Additional paremeters can be specified to further customize the input.
// Parameters can be optional
[RequiredInput(false)]
[InputLabel("Why??")]
diff --git a/Cocotte/Modules/Raids/IRaidsRepository.cs b/Cocotte/Modules/Raids/IRaidsRepository.cs
new file mode 100644
index 0000000..17529d2
--- /dev/null
+++ b/Cocotte/Modules/Raids/IRaidsRepository.cs
@@ -0,0 +1,8 @@
+namespace Cocotte.Modules.Raids;
+
+public interface IRaidsRepository
+{
+ Raid this[ulong raidId] { get; }
+
+ bool AddNewRaid(ulong raidId, DateTime dateTime);
+}
\ No newline at end of file
diff --git a/Cocotte/Modules/Raids/MemoryRaidRepository.cs b/Cocotte/Modules/Raids/MemoryRaidRepository.cs
new file mode 100644
index 0000000..3fd40b7
--- /dev/null
+++ b/Cocotte/Modules/Raids/MemoryRaidRepository.cs
@@ -0,0 +1,18 @@
+namespace Cocotte.Modules.Raids;
+
+public class MemoryRaidRepository : IRaidsRepository
+{
+ private readonly Dictionary _raids;
+
+ public Raid this[ulong raidId] => _raids[raidId];
+
+ public MemoryRaidRepository()
+ {
+ _raids = new Dictionary();
+ }
+
+ public bool AddNewRaid(ulong raidId, DateTime dateTime)
+ {
+ return _raids.TryAdd(raidId, new Raid(raidId, dateTime));
+ }
+}
\ No newline at end of file
diff --git a/Cocotte/Modules/Raids/Raid.cs b/Cocotte/Modules/Raids/Raid.cs
new file mode 100644
index 0000000..1332cc2
--- /dev/null
+++ b/Cocotte/Modules/Raids/Raid.cs
@@ -0,0 +1,38 @@
+using Cocotte.Utils;
+
+namespace Cocotte.Modules.Raids;
+
+public class Raid
+{
+ public ulong Id { get; }
+ public DateTime DateTime { get; }
+
+ private readonly RosterManager _rosterManager = new();
+
+ public IEnumerable> Rosters => _rosterManager.Rosters;
+
+ public Raid(ulong id, DateTime dateTime)
+ {
+ Id = id;
+ DateTime = dateTime;
+
+#if DEBUG
+ this.AddTestPlayers();
+#endif
+ }
+
+ public bool AddPlayer(string name, PlayerRole role, int fc, bool substitute = false)
+ {
+ return _rosterManager.AddPlayer(new RosterPlayer(name, role, fc, substitute));
+ }
+
+ public override bool Equals(object? other)
+ {
+ return other is Raid roster && roster.Id == Id;
+ }
+
+ public override int GetHashCode()
+ {
+ return (int) (Id % int.MaxValue);
+ }
+}
\ No newline at end of file
diff --git a/Cocotte/Modules/Raids/RaidModule.cs b/Cocotte/Modules/Raids/RaidModule.cs
new file mode 100644
index 0000000..476a0c5
--- /dev/null
+++ b/Cocotte/Modules/Raids/RaidModule.cs
@@ -0,0 +1,79 @@
+using System.Diagnostics.CodeAnalysis;
+using Cocotte.Utils;
+using Discord;
+using Discord.Interactions;
+
+namespace Cocotte.Modules.Raids;
+
+[Group("raid", "Raid related commands")]
+public class RaidModule : InteractionModuleBase
+{
+ private readonly ILogger _logger;
+ private readonly IRaidsRepository _raidsRepository;
+
+ public RaidModule(ILogger logger, IRaidsRepository raidsRepository)
+ {
+ _logger = logger;
+ _raidsRepository = raidsRepository;
+ }
+
+ [SlashCommand("start", "Start a raid formation")]
+ public async Task Ping()
+ {
+ // Raids are identified using their original message id
+ await RespondAsync("`Creating a new raid...`");
+
+ var response = await GetOriginalResponseAsync();
+ var raidId = response.Id;
+
+ _logger.LogInformation("Created new raid with id {RaidId}", raidId);
+
+ // New raid instance
+ // TODO: Ask for date
+ if (!_raidsRepository.AddNewRaid(raidId, DateTime.Now))
+ {
+ // A raid with this message id already exists, how??
+ _logger.LogWarning("Tried to create a new raid with already existing id: {RaidId}", raidId);
+
+ await FollowupAsync(ephemeral: true, embed: EmbedUtils.ErrorEmbed("Can't create a new raid with same raid id").Build());
+ await DeleteOriginalResponseAsync();
+
+ return;
+ }
+
+ // Build the raid message
+ var embed = RaidEmbed(raidId);
+
+ await ModifyOriginalResponseAsync(m =>
+ {
+ m.Content = "";
+ m.Embed = embed.Build();
+ });
+ }
+
+ [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")]
+ private EmbedBuilder RaidEmbed(ulong raidId)
+ {
+ EmbedFieldBuilder RosterEmbed(int rosterNumber, IEnumerable players)
+ {
+ var nonSubstitute = players.Where(p => !p.Substitue);
+ var substitute = players.Where(p => p.Substitue);
+
+ var separatorLength = Math.Max(nonSubstitute.Select(p => p.Name.Length).Max(), substitute.Select(p => p.Name.Length).Max());
+ separatorLength = (int) ((separatorLength + 13) * 0.49); // Don't ask why, it just works
+
+ return new EmbedFieldBuilder()
+ .WithName($"Roster {rosterNumber}")
+ .WithValue($"{string.Join("\n", nonSubstitute)}\n{new string('━', separatorLength)}\n{string.Join("\n", substitute)}")
+ .WithIsInline(true);
+ }
+
+ var raid = _raidsRepository[raidId];
+
+ return new EmbedBuilder()
+ .WithColor(Colors.CocotteBlue)
+ .WithTitle(":crossed_swords: Raid")
+ .WithDescription($"**Date:** {TimestampTag.FromDateTime(raid.DateTime, TimestampTagStyles.LongDateTime)}")
+ .WithFields(raid.Rosters.Select(r => RosterEmbed(r.Key, r)));
+ }
+}
\ No newline at end of file
diff --git a/Cocotte/Modules/Raids/RosterManager.cs b/Cocotte/Modules/Raids/RosterManager.cs
new file mode 100644
index 0000000..3d18ecc
--- /dev/null
+++ b/Cocotte/Modules/Raids/RosterManager.cs
@@ -0,0 +1,16 @@
+namespace Cocotte.Modules.Raids;
+
+public class RosterManager
+{
+ private readonly ISet _rosters = new HashSet();
+
+ public IEnumerable> Rosters => _rosters.GroupBy(p => p.RosterNumber);
+
+ public bool AddPlayer(RosterPlayer rosterPlayer)
+ {
+ // TODO add logic to split player in multiple rosters
+ rosterPlayer.RosterNumber = 1;
+
+ return _rosters.Add(rosterPlayer);
+ }
+}
\ No newline at end of file
diff --git a/Cocotte/Modules/Raids/RosterPlayer.cs b/Cocotte/Modules/Raids/RosterPlayer.cs
new file mode 100644
index 0000000..773a368
--- /dev/null
+++ b/Cocotte/Modules/Raids/RosterPlayer.cs
@@ -0,0 +1,33 @@
+namespace Cocotte.Modules.Raids;
+
+public record RosterPlayer(string Name, PlayerRole Role, int Fc, bool Substitue = false)
+{
+ public int RosterNumber { get; set; }
+
+ private static string RoleToEmote(PlayerRole role) => role switch
+ {
+ PlayerRole.Dps => ":red_circle:",
+ PlayerRole.Tank => ":yellow_circle:",
+ PlayerRole.Healer => ":green_circle:",
+ _ => ":question:"
+ };
+
+ public static string FcFormat(int fc) => fc switch
+ {
+ < 1_000 => $"{fc}",
+ _ => $"{fc/1000}k"
+ };
+
+ public override string ToString() => Substitue switch
+ {
+ false => $"{RoleToEmote(Role)} {Name} ({FcFormat(Fc)} FC)",
+ true => $"*{RoleToEmote(Role)} {Name} ({FcFormat(Fc)} FC)*"
+ };
+}
+
+public enum PlayerRole
+{
+ Dps,
+ Healer,
+ Tank
+}
\ No newline at end of file
diff --git a/Cocotte/Program.cs b/Cocotte/Program.cs
index 045fd0f..4b37408 100644
--- a/Cocotte/Program.cs
+++ b/Cocotte/Program.cs
@@ -1,3 +1,4 @@
+using Cocotte.Modules.Raids;
using Cocotte.Options;
using Cocotte.Services;
using Discord;
@@ -31,6 +32,9 @@ IHost host = Host.CreateDefaultBuilder(args)
services.AddHostedService();
+ // Data
+ services.AddSingleton();
+
// Custom
services.AddSingleton();
services.AddTransient();
diff --git a/Cocotte/Utils/Colors.cs b/Cocotte/Utils/Colors.cs
new file mode 100644
index 0000000..8ed8929
--- /dev/null
+++ b/Cocotte/Utils/Colors.cs
@@ -0,0 +1,13 @@
+using Discord;
+
+namespace Cocotte.Utils;
+
+public static class Colors
+{
+ // Main Cocotte colors
+ public static Color CocotteBlue => new(0x3196c8);
+
+ // Colors used in embeds
+ public static Color ErrorColor => new(0xFB6060);
+ public static Color InfoColor => new(0x66D9EF);
+}
\ No newline at end of file
diff --git a/Cocotte/Utils/EmbedUtils.cs b/Cocotte/Utils/EmbedUtils.cs
new file mode 100644
index 0000000..0277ec4
--- /dev/null
+++ b/Cocotte/Utils/EmbedUtils.cs
@@ -0,0 +1,28 @@
+using Discord;
+
+namespace Cocotte.Utils;
+
+public static class EmbedUtils
+{
+ public static EmbedBuilder ErrorEmbed(string message)
+ {
+ return new EmbedBuilder()
+ .WithColor(Colors.ErrorColor)
+ .WithAuthor(a => a
+ .WithName("Error")
+ .WithIconUrl("https://sage.cdn.ilysix.fr/assets/Cocotte/icons/error.webp")
+ )
+ .WithDescription(message);
+ }
+
+ public static EmbedBuilder InfoEmbed(string message)
+ {
+ return new EmbedBuilder()
+ .WithColor(Colors.InfoColor)
+ .WithAuthor(a => a
+ .WithName("Info")
+ .WithIconUrl("https://sage.cdn.ilysix.fr/assets/Cocotte/icons/info.webp")
+ )
+ .WithDescription(message);
+ }
+}
\ No newline at end of file
diff --git a/Cocotte/Utils/RaidExtensions.cs b/Cocotte/Utils/RaidExtensions.cs
new file mode 100644
index 0000000..ba4e405
--- /dev/null
+++ b/Cocotte/Utils/RaidExtensions.cs
@@ -0,0 +1,14 @@
+using Cocotte.Modules.Raids;
+
+namespace Cocotte.Utils;
+
+public static class RaidExtensions
+{
+ public static void AddTestPlayers(this Raid raid)
+ {
+ raid.AddPlayer("YamaRaja", PlayerRole.Healer, 30000, false);
+ raid.AddPlayer("Zaku", PlayerRole.Dps, 40000, false);
+ raid.AddPlayer("Juchi", PlayerRole.Tank, 40000, false);
+ raid.AddPlayer("Akeno", PlayerRole.Dps, 40000, true);
+ }
+}
\ No newline at end of file