diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 9e6c244..ce3efdc 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -39,9 +39,10 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup .NET + id: setup-dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: 8.0.101 - name: Restore run: dotnet restore - name: Inspect @@ -50,4 +51,5 @@ jobs: workingDirectory: './Bot' solutionPath: './Bot.sln' noBuild: true - minimumSeverity: 'warning' \ No newline at end of file + minimumSeverity: 'warning' + dotnetVersion: ${{ steps.setup-dotnet.outputs.dotnet-version }} \ No newline at end of file diff --git a/Bot/Clash/Coc.cs b/Bot/Clash/Coc.cs index 8fd6c4c..3032208 100644 --- a/Bot/Clash/Coc.cs +++ b/Bot/Clash/Coc.cs @@ -1,6 +1,8 @@ +using System.Diagnostics.CodeAnalysis; using ClashOfClans; using ClashOfClans.Core; using ClashOfClans.Models; +using ClashOfClans.Search; using Hyperstellar.Discord; using Hyperstellar.Sql; @@ -8,49 +10,27 @@ namespace Hyperstellar.Clash; internal static class Coc { + private class RaidAttackerComparer : IEqualityComparer + { + public bool Equals(ClanCapitalRaidSeasonAttacker? x, ClanCapitalRaidSeasonAttacker? y) => x!.Tag == y!.Tag; + public int GetHashCode([DisallowNull] ClanCapitalRaidSeasonAttacker obj) => obj.Tag.GetHashCode(); + } + private const string ClanId = "#2QU2UCJJC"; // 2G8LP8PVV private static readonly ClashOfClansClient s_client = new(Secrets.s_coc); - private static bool s_inMaintenance; + private static ClashOfClansException? s_exception; + private static ClanCapitalRaidSeason s_raidSeason; internal static ClanUtil Clan { get; private set; } = new(); - internal static event Action? s_eventMemberJoined; - internal static event Action? s_eventMemberLeft; - internal static event Func, Task>? EventDonated; - internal static event Func, Task>? EventDonatedFold; + internal static event Action EventMemberJoined; + internal static event Action EventMemberLeft; + internal static event Action EventInitRaid; + internal static event Action EventRaidCompleted; + internal static event Func, Task> EventDonated; + internal static event Func, Task> EventDonatedFold; +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. static Coc() => Dc.EventBotReady += BotReadyAsync; - - private static async Task BotReadyAsync() - { - while (true) - { - try - { - await PollAsync(); - s_inMaintenance = false; - await Task.Delay(10000); - } - catch (ClashOfClansException ex) - { - if (ex.Error.Reason == "inMaintenance") - { - if (!s_inMaintenance) - { - s_inMaintenance = true; - await Dc.SendLogAsync(ex.Error.Message); - } - await Task.Delay(60000); - } - else - { - await Dc.ExceptionAsync(ex); - } - } - catch (Exception ex) - { - await Dc.ExceptionAsync(ex); - } - } - } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. private static void CheckMembersJoined(ClanUtil clan) { @@ -66,7 +46,9 @@ private static void CheckMembersJoined(ClanUtil clan) foreach (ClanMember m in clan._joiningMembers.Values) { - s_eventMemberJoined!(m); + Main main = new(m.Tag); + EventMemberJoined!(m, main); + main.Insert(); } } @@ -81,6 +63,8 @@ private static void CheckMembersLeft(ClanUtil clan) { IEnumerable alts = new Member(id).GetAltsByMain(); string? altId = null; + Main main = Db.GetMain(id)!; + main.Delete(); if (alts.Any()) { Alt alt = alts.First(); @@ -90,8 +74,13 @@ private static void CheckMembersLeft(ClanUtil clan) alts.ElementAt(i).UpdateMain(alt.AltId); } alt.Delete(); + // Maybe adapt this in the future if need to modify attributes when replacing main + main.MainId = altId; + main.Insert(); } - s_eventMemberLeft!(member, altId); // This is before Db.DelMem below so that we can remap Donation to new mainId + // This is before Db.DelMem below so that we can remap Donation to new mainId + // ^ No longer true because the remap is done ABOVE now but I'll still leave this comment + EventMemberLeft!(member, altId); } string[] members = [.. clan._leavingMembers.Keys]; @@ -100,6 +89,33 @@ private static void CheckMembersLeft(ClanUtil clan) Console.WriteLine($"{membersMsg} left"); } + private static async Task BotReadyAsync() + { + while (true) + { + try + { + await PollAsync(); + s_exception = null; + await Task.Delay(10000); + } + catch (ClashOfClansException ex) + { + if (s_exception == null || s_exception.Error.Reason != ex.Error.Reason || s_exception.Error.Message != ex.Error.Message) + { + s_exception = ex; + await Dc.ExceptionAsync(ex); + } + await Task.Delay(60000); + } + catch (Exception ex) + { + await Dc.ExceptionAsync(ex); + await Task.Delay(60000); + } + } + } + private static async Task GetClanAsync() => await s_client.Clans.GetClanAsync(ClanId); private static async Task PollAsync() @@ -112,6 +128,7 @@ private static async Task PollAsync() } ClanUtil clanUtil = ClanUtil.FromPoll(clan); + CheckMembersJoined(clanUtil); CheckMembersLeft(clanUtil); await Task.WhenAll([ @@ -120,6 +137,37 @@ await Task.WhenAll([ Clan = clanUtil; } + private static async Task PollRaidAsync() + { + static async Task WaitRaidAsync() + { + await Task.Delay(s_raidSeason.EndTime - DateTime.UtcNow); + s_raidSeason = await GetRaidSeasonAsync(); + while (s_raidSeason.State != ClanCapitalRaidSeasonState.Ended) + { + await Task.Delay(20000); + s_raidSeason = await GetRaidSeasonAsync(); + } + EventRaidCompleted(s_raidSeason); + } + + // Check if there is an ongoing raid + if (s_raidSeason.EndTime > DateTime.UtcNow) + { + await WaitRaidAsync(); + } + while (true) + { + await Task.Delay(60 * 60 * 1000); // 1 hour + ClanCapitalRaidSeason season = await GetRaidSeasonAsync(); + if (season.StartTime != s_raidSeason.StartTime) // New season started + { + s_raidSeason = season; + await WaitRaidAsync(); + } + } + } + private static async Task CheckDonationsAsync(ClanUtil clan) { Dictionary donDelta = []; @@ -165,11 +213,11 @@ private static async Task CheckDonationsAsync(ClanUtil clan) ICollection tasks = []; if (donDelta.Count > 0) { - tasks.Add(EventDonated!(donDelta)); + tasks.Add(EventDonated(donDelta)); } if (foldedDelta.Count > 0) { - tasks.Add(EventDonatedFold!(foldedDelta)); + tasks.Add(EventDonatedFold(foldedDelta)); } await Task.WhenAll(tasks); } @@ -182,5 +230,44 @@ private static async Task CheckDonationsAsync(ClanUtil clan) internal static ClanMember GetMember(string id) => Clan._members[id]; - internal static async Task InitAsync() => Clan = ClanUtil.FromInit(await GetClanAsync()); + internal static HashSet GetRaidAttackers(ClanCapitalRaidSeason season) + { + HashSet set = new(new RaidAttackerComparer()); + foreach (ClanCapitalRaidSeasonAttackLogEntry capital in season.AttackLog) + { + foreach (ClanCapitalRaidSeasonDistrict district in capital.Districts) + { + if (district.Attacks != null) + { + foreach (ClanCapitalRaidSeasonAttack atk in district.Attacks) + { + set.Add(atk.Attacker); + } + } + } + } + return set; + } + + internal static async Task GetRaidSeasonAsync() + { + Query query = new() { Limit = 1 }; + ClanCapitalRaidSeasons seasons = (ClanCapitalRaidSeasons)await s_client.Clans.GetCapitalRaidSeasonsAsync(ClanId, query); + return seasons.First(); + } + + internal static async Task InitAsync() + { + static async Task InitClanAsync() { Clan = ClanUtil.FromInit(await GetClanAsync()); } + static async Task InitRaidAsync() + { + ClanCapitalRaidSeason season = await GetRaidSeasonAsync(); + // If last raid happened within a week, we count it as valid + EventInitRaid(season); + s_raidSeason = season; + _ = Task.Run(PollRaidAsync); + } + + await Task.WhenAll([InitClanAsync(), InitRaidAsync()]); + } } diff --git a/Bot/Clash/Donate25.cs b/Bot/Clash/Phaser.cs similarity index 67% rename from Bot/Clash/Donate25.cs rename to Bot/Clash/Phaser.cs index 633673c..622567b 100644 --- a/Bot/Clash/Donate25.cs +++ b/Bot/Clash/Phaser.cs @@ -4,7 +4,7 @@ namespace Hyperstellar.Clash; -internal static class Donate25 +internal static class Phaser { private sealed class Node(long time) { @@ -17,13 +17,16 @@ private sealed class Node(long time) private static readonly Queue s_queue = []; // Queue for the await task internal static event Func, Task>? EventViolated; - static Donate25() + static Phaser() { - Coc.s_eventMemberJoined += MemberAdded; - Coc.s_eventMemberLeft += MemberLeft; + Coc.EventMemberJoined += MemberAdded; + Coc.EventMemberLeft += MemberLeft; Coc.EventDonatedFold += DonationChanged; + Coc.EventInitRaid += InitRaid; + Coc.EventRaidCompleted += ProcessRaid; Dc.EventBotReady += BotReadyAsync; Member.EventAltAdded += AltAdded; + Init(); } @@ -37,34 +40,50 @@ private static void DebugQueue() Console.WriteLine(string.Join("\n", msgs)); } + private static void ProcessRaid(ClanCapitalRaidSeason season) + { + foreach (ClanCapitalRaidSeasonAttacker atk in Coc.GetRaidAttackers(season)) + { + // If in the db, mark as raided + Member? member = Db.GetMember(atk.Tag); + if (member != null) + { + Main main = member.GetEffectiveMain(); + main.Raided = true; + main.Update(); + } + } + } + private static void Init() { - IEnumerable> donationGroups = Db.GetDonations() + IEnumerable> donationGroups = Db.GetDonations() .GroupBy(d => d.Checked) .OrderBy(g => g.Key); DateTimeOffset now = DateTimeOffset.UtcNow; - Node expiredNode = new(now.ToUnixTimeSeconds() + CheckPeriod); // Node for expired donations + Node expiredNode = new(GetNowNextTime()); // Node for expired donations - foreach (IGrouping group in donationGroups) + foreach (IGrouping group in donationGroups) { DateTimeOffset lastChecked = DateTimeOffset.FromUnixTimeSeconds(group.Key); // If bot was down when a check is due, we will be lenient and wait for another cycle if (now >= lastChecked) { - foreach (Donation donation in group) + foreach (Main main in group) { - expiredNode._ids.Add(donation.MainId); - donation.Checked = now.ToUnixTimeSeconds(); - donation.Update(); + expiredNode._ids.Add(main.MainId); + main.Checked = expiredNode._checkTime; + main.Donated = 0; + main.Update(); } } else { Node node = new(group.Key); - foreach (Donation donation in group) + foreach (Main main in group) { - node._ids.Add(donation.MainId); + node._ids.Add(main.MainId); } s_queue.Enqueue(node); } @@ -77,68 +96,6 @@ private static void Init() Console.WriteLine("[Donate25] Inited"); } - private static async Task BotReadyAsync() - { - try - { - await CheckQueueAsync(); - } - catch (Exception ex) - { - await Dc.ExceptionAsync(ex); - } - } - - private static async Task CheckQueueAsync() - { - while (s_queue.Count > 0) - { - Node node = s_queue.First(); - if (node._ids.Count == 0) - { - s_queue.Dequeue(); - continue; - } - - int waitDelay = (int)((node._checkTime * 1000) - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); - await Task.Delay(waitDelay); - - node = s_queue.Dequeue(); - node._checkTime += CheckPeriod; - List violators = []; - foreach (string member in node._ids) - { - IEnumerable alts = new Member(member).GetAltsByMain(); - int altCount = alts.Count(); - int donationTarget = TargetPerPerson * (altCount + 1); - Donation donation = Db.GetDonation(member)!; - if (donation.Donated >= donationTarget) - { - Console.WriteLine($"[Donate25] {member} new cycle"); - } - else - { - violators.Add(member); - Console.WriteLine($"[Donate25] {member} violated"); - } - donation.Donated = 0; - donation.Checked = node._checkTime; - donation.Update(); - } - - if (node._ids.Count > 0) - { - s_queue.Enqueue(node); - } - DebugQueue(); - - if (violators.Count > 0) - { - await EventViolated!(violators); - } - } - } - private static Task DonationChanged(Dictionary foldedDelta) { foreach ((string tag, DonationTuple dt) in foldedDelta) @@ -149,38 +106,35 @@ private static Task DonationChanged(Dictionary foldedDelt if (donated > received) { donated -= received; - Donation donation = Db.GetDonation(tag)!; - donation.Donated += (uint)donated; + Main main = Db.GetMain(tag)!; + main.Donated += (uint)donated; Console.WriteLine($"[Donate25] {tag} {donated}"); - Db.UpdateDonation(donation); + Db.UpdateMain(main); } } return Task.CompletedTask; } - private static void AltAdded(Alt alt) + private static void AltAdded(Main altMain, Main mainMain) { - string altId = alt.AltId, mainId = alt.MainId; + string altId = altMain.MainId, mainId = mainMain.MainId; Console.WriteLine($"[Donate25] Removing {altId} -> {mainId} (addalt)"); Node? node = s_queue.FirstOrDefault(n => n._ids.Remove(altId)); if (node != null) { Console.WriteLine($"[Donate25] Removed {altId} in {node._checkTime}"); node._ids.Add(mainId); - Donation altDon = Db.GetDonation(altId)!; - Donation mainDon = Db.GetDonation(mainId)!; - altDon.Delete(); - mainDon.Donated += altDon.Donated; - mainDon.Update(); + mainMain.Donated += altMain.Donated; Console.WriteLine($"[Donate25] Added {mainId} because it replaced {altId} as main"); } } - private static void MemberAdded(ClanMember member) + private static void MemberAdded(ClanMember member, Main main) { + main.Checked = GetNowNextTime(); string id = member.Tag; Console.WriteLine($"[Donate25] Adding {id}"); - long targetTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + CheckPeriod; + long targetTime = GetNowNextTime(); Node node = s_queue.Last(); // We expect at least 1 member in the db if (targetTime == node._checkTime) { @@ -211,12 +165,97 @@ private static void MemberLeft(ClanMember member, string? newMainId) if (newMainId != null) { node._ids.Add(newMainId); - Donation donation = Db.GetDonation(id)!; - donation.Delete(); - donation.MainId = newMainId; - donation.Insert(); Console.WriteLine($"[Donate25] Added {newMainId} because it replaced {id} as main"); } } } + + private static void InitRaid(ClanCapitalRaidSeason season) + { + if (!IsDatetimeExpired(season.StartTime)) + { + ProcessRaid(season); + } + } + + private static long GetNowNextTime() => DateTimeOffset.UtcNow.ToUnixTimeSeconds() + CheckPeriod; + + private static async Task BotReadyAsync() + { + try + { + await CheckQueueAsync(); + } + catch (Exception ex) + { + await Dc.ExceptionAsync(ex); + } + } + + private static async Task CheckQueueAsync() + { + while (s_queue.Count > 0) + { + Node node = s_queue.First(); + if (node._ids.Count == 0) + { + s_queue.Dequeue(); + continue; + } + + int waitDelay = (int)((node._checkTime * 1000) - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + await Task.Delay(waitDelay); + + node = s_queue.Dequeue(); + node._checkTime += CheckPeriod; + List violators = []; + List violators2 = []; + foreach (string member in node._ids) + { + IEnumerable alts = new Member(member).GetAltsByMain(); + int altCount = alts.Count(); + int donationTarget = TargetPerPerson * (altCount + 1); + Main main = Db.GetMain(member)!; + if (main.Donated >= donationTarget) + { + Console.WriteLine($"[Donate25] {member} new cycle"); + } + else + { + violators.Add(member); + Console.WriteLine($"[Donate25] {member} violated"); + } + if (!main.Raided) + { + violators2.Add(member); + Console.WriteLine($"[Phaser] {member} not raided"); + } + else + { + Console.WriteLine($"[Phaser] {member} raided"); + } + main.Donated = 0; + main.Raided = false; + main.Checked = node._checkTime; + main.Update(); + } + + if (node._ids.Count > 0) + { + s_queue.Enqueue(node); + } + DebugQueue(); + + if (violators.Count > 0) + { + await EventViolated!(violators); + } + if (violators2.Count > 0) + { + await EventViolated!(violators2); + } + } + } + + internal static bool IsDatetimeExpired(DateTimeOffset time) => (DateTimeOffset.UtcNow - time).TotalSeconds >= CheckPeriod; } diff --git a/Bot/Discord/Cmds.cs b/Bot/Discord/Cmds.cs index 108f325..f082faa 100644 --- a/Bot/Discord/Cmds.cs +++ b/Bot/Discord/Cmds.cs @@ -5,6 +5,7 @@ using Hyperstellar.Discord.Attr; using Hyperstellar.Sql; using Hyperstellar.Clash; +using Discord; namespace Hyperstellar.Discord; @@ -87,5 +88,24 @@ public async Task InfoAsync(Member member) }; await RespondAsync(embed: embed.Build()); + + [RequireAdmin] + [SlashCommand("link", "[Admin] Links a Discord account to a Main")] + public async Task LinkAsync(Member coc, IGuildUser discord) + { + Main? main = coc.TryToMain(); + if (main == null) + { + await RespondAsync("`coc` can't be an alt!"); + return; + } + if (discord.IsBot) + { + await RespondAsync("`discord` can't be a bot!"); + return; + } + main.Discord = discord.Id; + main.Update(); + await RespondAsync("Linked"); } } diff --git a/Bot/Discord/Dc.cs b/Bot/Discord/Dc.cs index 4086c3f..0a1ca5f 100644 --- a/Bot/Discord/Dc.cs +++ b/Bot/Discord/Dc.cs @@ -23,7 +23,7 @@ static Dc() s_interactionSvc.AddTypeConverter(new MemberConverter()); Coc.EventDonated += DonationsChangedAsync; - Donate25.EventViolated += Donate25Async; + Phaser.EventViolated += Donate25Async; s_bot.Log += Log; s_bot.Ready += Ready; s_bot.SlashCommandExecuted += SlashCmdXAsync; diff --git a/Bot/Sql/Db.cs b/Bot/Sql/Db.cs index 1344b44..9a9b57e 100644 --- a/Bot/Sql/Db.cs +++ b/Bot/Sql/Db.cs @@ -18,21 +18,20 @@ internal static void Commit() internal static bool AddMembers(string[] members) { int memberCount = s_db.InsertAll(members.Select(m => new Member(m))); - int donationCount = s_db.InsertAll(members.Select(m => new Donation(m))); - return memberCount == donationCount && memberCount == members.Length; + return memberCount == members.Length; } internal static bool DeleteMembers(string[] members) => members.Sum(s_db.Delete) == members.Length; internal static Member? GetMember(string member) => s_db.Table().FirstOrDefault(m => m.CocId.Equals(member)); - internal static Donation? GetDonation(string id) => s_db.Table().FirstOrDefault(d => d.MainId == id); + internal static Main? GetMain(string id) => s_db.Table
().FirstOrDefault(d => d.MainId == id); - internal static IEnumerable GetDonations() => s_db.Table(); + internal static IEnumerable
GetDonations() => s_db.Table
(); - internal static bool UpdateDonation(Donation donation) => s_db.Update(donation) == 1; + internal static bool UpdateMain(Main main) => s_db.Update(main) == 1; - internal static Alt? GetAlt(string altId) => s_db.Table().FirstOrDefault(a => a.AltId == altId); + internal static Alt? TryGetAlt(string altId) => s_db.Table().FirstOrDefault(a => a.AltId == altId); internal static bool AddAdmin(ulong id) { diff --git a/Bot/Sql/Donation.cs b/Bot/Sql/Main.cs similarity index 60% rename from Bot/Sql/Donation.cs rename to Bot/Sql/Main.cs index 536272d..afdb594 100644 --- a/Bot/Sql/Donation.cs +++ b/Bot/Sql/Main.cs @@ -2,7 +2,7 @@ namespace Hyperstellar.Sql; -internal sealed class Donation(string id, long checkTime) +public sealed class Main(string id) { [PrimaryKey, NotNull] public string MainId { get; set; } = id; @@ -11,11 +11,15 @@ internal sealed class Donation(string id, long checkTime) public uint Donated { get; set; } [NotNull] - public long Checked { get; set; } = checkTime; + public long Checked { get; set; } - public Donation() : this("") { } + [Unique] + public ulong? Discord { get; set; } - public Donation(string id) : this(id, DateTimeOffset.UtcNow.ToUnixTimeSeconds()) { } + [NotNull] + public bool Raided { get; set; } + + public Main() : this("") { } public bool Delete() => Db.s_db.Delete(this) == 1; diff --git a/Bot/Sql/Member.cs b/Bot/Sql/Member.cs index 2f08661..b034e51 100644 --- a/Bot/Sql/Member.cs +++ b/Bot/Sql/Member.cs @@ -2,31 +2,28 @@ using SQLite; namespace Hyperstellar.Sql; -public class Member +public class Member(string cocId) { - internal static event Action? EventAltAdded; + internal static event Action? EventAltAdded; [PrimaryKey, NotNull] - public string CocId { get; set; } - - [Unique] - public ulong? DiscordId { get; set; } + public string CocId { get; set; } = cocId; public Member() : this("") { } - public Member(string cocId) => CocId = cocId; - - public Member(string cocId, ulong discordId) - { - CocId = cocId; - DiscordId = discordId; - } - public void AddAlt(Member altMember) { Alt alt = new(altMember.CocId, CocId); Db.s_db.Insert(alt); - EventAltAdded!(alt); + Main altMain = Db.GetMain(altMember.CocId)!; + Main mainMain = Db.GetMain(CocId)!; + EventAltAdded!(altMain, mainMain); + if (altMain.Discord != null) + { + mainMain.Discord = altMain.Discord; + } + mainMain.Update(); + altMain.Delete(); } public bool IsAlt() @@ -43,7 +40,16 @@ public bool IsAltMain() public Alt? TryToAlt() => Db.s_db.Table().FirstOrDefault(a => a.AltId == CocId); + public Main? TryToMain() => Db.s_db.Table
().FirstOrDefault(m => m.MainId == CocId); + public TableQuery GetAltsByMain() => Db.s_db.Table().Where(a => a.MainId == CocId); public string GetName() => Coc.GetMember(CocId).Name; + + public Main GetEffectiveMain() + { + Alt? alt = TryToAlt(); + string mainId = alt == null ? CocId : alt.MainId; + return Db.s_db.Table
().First(m => m.MainId == mainId); + } }