diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..efc07e5 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.1", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f9afb14..67ab4d9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ ctf.*.json data obj *.binlog +/data_bk diff --git a/ENOWARS.ruleset b/ENOWARS.ruleset index 28f987b..f3babb1 100644 --- a/ENOWARS.ruleset +++ b/ENOWARS.ruleset @@ -69,6 +69,7 @@ + diff --git a/EnoConfig/Program.cs b/EnoConfig/Program.cs index 12e5e83..bd67d51 100644 --- a/EnoConfig/Program.cs +++ b/EnoConfig/Program.cs @@ -82,10 +82,6 @@ public static int Main(string[] args) debugFlagsCommand.Handler = CommandHandler.Create(async (round, encoding, signing_key) => await program.Flags(round, encoding, signing_key)); rootCommand.AddCommand(debugFlagsCommand); - var roundWarpCommand = new Command("newround", "Start new round"); - roundWarpCommand.Handler = CommandHandler.Create(program.NewRound); - rootCommand.AddCommand(roundWarpCommand); - return rootCommand.InvokeAsync(args).Result; } @@ -415,43 +411,6 @@ public async Task Flags(int round, FlagEncoding encoding, string signing_ke return 0; } - public async Task NewRound() - { - using var scope = this.serviceProvider.CreateScope(); - using var dbContext = scope.ServiceProvider.GetRequiredService(); - var lastRound = await dbContext.Rounds - .OrderByDescending(r => r.Id) - .FirstOrDefaultAsync(); - - Round round; - if (lastRound != null) - { - round = new Round( - lastRound.Id + 1, - DateTime.UtcNow, - DateTime.UtcNow, - DateTime.UtcNow, - DateTime.UtcNow, - DateTime.UtcNow); - } - else - { - round = new Round( - 1, - DateTime.UtcNow, - DateTime.UtcNow, - DateTime.UtcNow, - DateTime.UtcNow, - DateTime.UtcNow); - } - - Console.WriteLine($"Adding round {round}"); - dbContext.Add(round); - await dbContext.SaveChangesAsync(); - - return 0; - } - private static JsonConfiguration? LoadConfig(FileInfo input) { if (!input.Exists) diff --git a/EnoCore.Models/AttackInfo/AttackInfo.cs b/EnoCore.Models/AttackInfo/AttackInfo.cs index c733ed0..c7ef820 100644 --- a/EnoCore.Models/AttackInfo/AttackInfo.cs +++ b/EnoCore.Models/AttackInfo/AttackInfo.cs @@ -1,18 +1,18 @@ -namespace EEnoCore.Models.AttackInfo; - -public record AttackInfo( - string[] AvailableTeams, - Dictionary Services); - -#pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable SA1502 // Element should not be on a single line -public class AttackInfoService : Dictionary -{ } - -public class AttackInfoServiceTeam : Dictionary -{ } - -public class AttackInfoServiceTeamRound : Dictionary -{ } -#pragma warning restore SA1502 -#pragma warning restore SA1402 +namespace EEnoCore.Models.AttackInfo; + +public record AttackInfo( + string[] AvailableTeams, + Dictionary Services); + +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1502 // Element should not be on a single line +public class AttackInfoService : Dictionary +{ } + +public class AttackInfoServiceTeam : Dictionary +{ } + +public class AttackInfoServiceTeamRound : Dictionary +{ } +#pragma warning restore SA1502 +#pragma warning restore SA1402 diff --git a/EnoCore.Models/Database/Round.cs b/EnoCore.Models/Database/Round.cs index 5cc6773..867ccad 100644 --- a/EnoCore.Models/Database/Round.cs +++ b/EnoCore.Models/Database/Round.cs @@ -1,8 +1,28 @@ namespace EnoCore.Models.Database; -public sealed record Round(long Id, - DateTimeOffset Begin, - DateTimeOffset Quarter2, - DateTimeOffset Quarter3, - DateTimeOffset Quarter4, - DateTimeOffset End); +public enum RoundStatus +{ + Prepared, + Running, + Finished, + Scored, +} + +public sealed record Round +{ + public Round(long id, DateTimeOffset? begin, DateTimeOffset? end, RoundStatus status) + { + this.Id = id; + this.Begin = begin; + this.End = end; + this.Status = status; + } + + public long Id { get; set; } + + public DateTimeOffset? Begin { get; set; } + + public DateTimeOffset? End { get; set; } + + public RoundStatus Status { get; set; } +} diff --git a/EnoDatabase/EnoDb.AttackInfo.cs b/EnoDatabase/EnoDb.AttackInfo.cs deleted file mode 100644 index a93c187..0000000 --- a/EnoDatabase/EnoDb.AttackInfo.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace EnoDatabase; - -public partial class EnoDb -{ - public async Task GetAttackInfo(long roundId, long flagValidityInRounds) - { - var teamAddresses = await this.context.Teams - .AsNoTracking() - .Select(t => new { t.Id, t.Address }) - .ToDictionaryAsync(t => t.Id, t => t.Address); - var availableTeams = await this.context.RoundTeamServiceStatus - .Where(rtss => rtss.GameRoundId == roundId) - .GroupBy(rtss => rtss.TeamId) - .Select(g => new { g.Key, BestResult = g.Min(rtss => rtss.Status) }) - .Where(ts => ts.BestResult < ServiceStatus.OFFLINE) - .Select(ts => ts.Key) - .OrderBy(ts => ts) - .ToArrayAsync(); - var availableTeamAddresses = availableTeams.Select(id => teamAddresses[id] ?? id.ToString()).ToArray(); - - var serviceNames = await this.context.Services - .AsNoTracking() - .Select(s => new { s.Id, s.Name }) - .ToDictionaryAsync(s => s.Id, s => s.Name); - - var relevantTasks = await this.context.CheckerTasks - .AsNoTracking() - .Where(ct => ct.CurrentRoundId > roundId - flagValidityInRounds) - .Where(ct => ct.CurrentRoundId <= roundId) - .Where(ct => ct.Method == CheckerTaskMethod.putflag) - .Where(ct => ct.AttackInfo != null) - .Select(ct => new { ct.AttackInfo, ct.VariantId, ct.CurrentRoundId, ct.TeamId, ct.ServiceId }) - .OrderBy(ct => ct.ServiceId) - .ThenBy(ct => ct.TeamId) - .ThenBy(ct => ct.CurrentRoundId) - .ThenBy(ct => ct.VariantId) - .ToArrayAsync(); - var groupedTasks = relevantTasks - .GroupBy(ct => new { ct.VariantId, ct.CurrentRoundId, ct.TeamId, ct.ServiceId }) - .GroupBy(g => new { g.Key.CurrentRoundId, g.Key.TeamId, g.Key.ServiceId }) - .GroupBy(g => new { g.Key.TeamId, g.Key.ServiceId }) - .GroupBy(g => new { g.Key.ServiceId }); - - var services = new Dictionary(); - foreach (var serviceTasks in groupedTasks) - { - var service = new AttackInfoService(); - foreach (var teamTasks in serviceTasks) - { - var team = new AttackInfoServiceTeam(); - foreach (var roundTasks in teamTasks) - { - var round = new AttackInfoServiceTeamRound(); - foreach (var variantTasks in roundTasks) - { - string[] attackInfos = variantTasks.Select(ct => ct.AttackInfo!).ToArray(); - round.Add(variantTasks.Key.VariantId, attackInfos); - } - - team.Add(roundTasks.Key.CurrentRoundId, round); - } - - service.TryAdd(teamAddresses[teamTasks.Key.TeamId] ?? teamTasks.Key.TeamId.ToString(), team); - } - - services.TryAdd(serviceNames[serviceTasks.Key.ServiceId] ?? serviceTasks.Key.ServiceId.ToString(), service); - } - - var attackInfo = new AttackInfo(availableTeamAddresses, services); - return attackInfo; - } -} diff --git a/EnoDatabase/EnoDb.Scoring.cs b/EnoDatabase/EnoDb.Scoring.cs deleted file mode 100644 index d1d4e9e..0000000 --- a/EnoDatabase/EnoDb.Scoring.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System.Text.RegularExpressions; -using EnoCore.Models.Database; -using Microsoft.EntityFrameworkCore; - -namespace EnoDatabase; // #pragma warning disable SA1118 - -public record TeamResults(long TeamId, long ServiceId, long RoundId, double AttackPoints, double LostDefensePoints, double ServiceLevelAgreementPoints); -public record Results(long TeamId, long ServiceId, double Points); -public record SLAResults( - long TeamId, - long ServiceId, - double Points, - TeamServicePointsSnapshot? Snapshot, - ServiceStatus Status); - -public partial class EnoDb -{ - private const double SLA = 100.0; - private const double ATTACK = 1000.0; - private const double DEF = -50; - - public string GetQuery(long minRoundId, long maxRoundId, double storeWeightFactor, double servicesWeightFactor) - { - Debug.Assert(storeWeightFactor > 0, "Invalid store weight"); - Debug.Assert(servicesWeightFactor > 0, "Invalid services weight"); - long oldSnapshotRoundId = minRoundId - 1; - var query = - from team in this.context.Teams - from service in this.context.Services - select new - { - TeamId = team.Id, - ServiceId = service.Id, - RoundId = maxRoundId, - AttackPoints = this.context.SubmittedFlags // service, attacker, round - .Where(sf => sf.FlagServiceId == service.Id) - .Where(sf => sf.AttackerTeamId == team.Id) - .Where(sf => sf.RoundId <= maxRoundId) - .Where(sf => sf.RoundId >= minRoundId) - .Sum(sf => ATTACK - * this.context.Services.Where(e => e.Id == service.Id).Single().WeightFactor / servicesWeightFactor // Service Weight Scaling - / this.context.Services.Where(e => e.Id == service.Id).Single().FlagsPerRound - / this.context.Services.Where(e => e.Id == service.Id).Single().FlagVariants - / this.context.SubmittedFlags // service, owner, round (, offset) - .Where(e => e.FlagServiceId == sf.FlagServiceId) - .Where(e => e.FlagOwnerId == sf.FlagOwnerId) - .Where(e => e.FlagRoundId == sf.FlagRoundId) - .Where(e => e.FlagRoundOffset == sf.FlagRoundOffset) - .Count() // Other attackers - / this.context.Teams.Where(e => e.Active).Count()) - + Math.Max( - this.context.TeamServicePointsSnapshot - .Where(e => e.RoundId == oldSnapshotRoundId) - .Where(e => e.TeamId == team.Id) - .Where(e => e.ServiceId == service.Id) - .Single().AttackPoints, - 0.0), - LostDefensePoints = (DEF - * this.context.Services.Where(e => e.Id == service.Id).Single().WeightFactor / servicesWeightFactor - / this.context.Services.Where(e => e.Id == service.Id).Single().FlagsPerRound - * this.context.SubmittedFlags // service, owner, round - .Where(e => e.FlagServiceId == service.Id) - .Where(e => e.FlagOwnerId == team.Id) - .Where(e => e.FlagRoundId <= maxRoundId) - .Where(e => e.FlagRoundId >= minRoundId) - .Select(e => new { e.FlagServiceId, e.FlagOwnerId, e.FlagRoundId, e.FlagRoundOffset }) - .Distinct() // Lost flags - .Count()) - + Math.Min( - this.context.TeamServicePointsSnapshot - .Where(e => e.RoundId == oldSnapshotRoundId) - .Where(e => e.TeamId == team.Id) - .Where(e => e.ServiceId == service.Id) - .Single().LostDefensePoints, - 0.0), - ServiceLevelAgreementPoints = this.context.RoundTeamServiceStatus - .Where(e => e.GameRoundId <= maxRoundId) - .Where(e => e.GameRoundId >= minRoundId) - .Where(e => e.TeamId == team.Id) - .Where(e => e.ServiceId == service.Id) - .Sum(sla => SLA - * this.context.Services.Where(s => s.Id == s.Id).Single().WeightFactor - * (sla.Status == ServiceStatus.OK ? 1 : sla.Status == ServiceStatus.RECOVERING ? 0.5 : 0) - / servicesWeightFactor) - + Math.Max( - this.context.TeamServicePointsSnapshot - .Where(e => e.RoundId == oldSnapshotRoundId) - .Where(e => e.TeamId == team.Id) - .Where(e => e.ServiceId == service.Id) - .Single().ServiceLevelAgreementPoints, - 0.0), - Status = this.context.RoundTeamServiceStatus - .Where(e => e.GameRoundId == maxRoundId) - .Where(e => e.TeamId == team.Id) - .Where(e => e.ServiceId == service.Id) - .Select(e => e.Status) - .Single(), - ErrorMessage = this.context.RoundTeamServiceStatus - .Where(e => e.GameRoundId == maxRoundId) - .Where(e => e.TeamId == team.Id) - .Where(e => e.ServiceId == service.Id) - .Select(e => e.ErrorMessage) - .Single(), - }; - - var queryString = query.ToQueryString(); - queryString = queryString.Replace("@__maxRoundId_0", maxRoundId.ToString()); - queryString = queryString.Replace("@__minRoundId_1", minRoundId.ToString()); - queryString = queryString.Replace("@__servicesWeightFactor_2", servicesWeightFactor.ToString()); - queryString = queryString.Replace("@__oldSnapshotRoundId_3", (minRoundId - 1).ToString()); - queryString = queryString.Replace("@__storeWeightFactor_4", storeWeightFactor.ToString()); - return queryString; - } - - public async Task UpdateScores(long roundId, Configuration configuration) - { - double servicesWeightFactor = await this.context.Services.Where(s => s.Active).SumAsync(s => s.WeightFactor); - double storeWeightFactor = await this.context.Services.Where(s => s.Active).SumAsync(s => s.WeightFactor * s.FlagVariants); - var newSnapshotRoundId = roundId - configuration.FlagValidityInRounds - 5; - var sw = new Stopwatch(); - - // Phase 2: Create new TeamServicePointsSnapshots, if required - sw.Restart(); - if (newSnapshotRoundId > 0) - { - var query = this.GetQuery(newSnapshotRoundId, newSnapshotRoundId, storeWeightFactor, servicesWeightFactor); - var phase2QueryRaw = @$" -WITH cte AS ( - SELECT ""TeamId"", ""ServiceId"", ""RoundId"", ""AttackPoints"", ""LostDefensePoints"", ""ServiceLevelAgreementPoints"" - FROM ( ------------------ -{query} ------------------ - ) as k -) -INSERT INTO ""TeamServicePointsSnapshot"" (""TeamId"", ""ServiceId"", ""RoundId"", ""AttackPoints"", ""LostDefensePoints"", ""ServiceLevelAgreementPoints"") -- Mind that the order is important! -SELECT * FROM cte -"; - await this.context.Database.ExecuteSqlRawAsync(phase2QueryRaw); - } - - Console.WriteLine($"Phase 2 done in {sw.ElapsedMilliseconds}ms"); - - // Phase 3: Update TeamServicePoints - sw.Restart(); - var phase3Query = this.GetQuery(newSnapshotRoundId + 1, roundId, storeWeightFactor, servicesWeightFactor); - var phase3QueryRaw = @$" -WITH cte AS ( ------------------ -{phase3Query} ------------------ -) -UPDATE - ""TeamServicePoints"" -SET - ""AttackPoints"" = cte.""AttackPoints"", - ""DefensePoints"" = cte.""LostDefensePoints"", - ""ServiceLevelAgreementPoints"" = cte.""ServiceLevelAgreementPoints"", - ""Status"" = cte.""Status"", - ""ErrorMessage"" = cte.""ErrorMessage"" -FROM cte -WHERE - ""TeamServicePoints"".""TeamId"" = cte.""TeamId"" AND - ""TeamServicePoints"".""ServiceId"" = cte.""ServiceId"" -;"; - await this.context.Database.ExecuteSqlRawAsync(phase3QueryRaw); - Console.WriteLine($"Phase 3 done in {sw.ElapsedMilliseconds}ms"); - - // Phase 4: Update Teams - sw.Restart(); - foreach (var team in await this.context.Teams.ToArrayAsync()) - { - team.AttackPoints = await this.context.TeamServicePoints - .Where(e => e.TeamId == team.Id) - .Select(e => e.AttackPoints) - .SumAsync(); - - team.DefensePoints = await this.context.TeamServicePoints - .Where(e => e.TeamId == team.Id) - .Select(e => e.DefensePoints) - .SumAsync(); - - team.ServiceLevelAgreementPoints = await this.context.TeamServicePoints - .Where(e => e.TeamId == team.Id) - .Select(e => e.ServiceLevelAgreementPoints) - .SumAsync(); - - team.TotalPoints = team.AttackPoints + team.DefensePoints + team.ServiceLevelAgreementPoints; - } - - await this.context.SaveChangesAsync(); - Console.WriteLine($"Phase 4 done in {sw.ElapsedMilliseconds}ms"); - } - - public async Task GetCurrentScoreboard(long roundId) - { - var sw = new Stopwatch(); - sw.Start(); - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Started Scoreboard Generation"); - var teams = this.context.Teams - .Include(t => t.TeamServicePoints) - .AsNoTracking() - .OrderByDescending(t => t.TotalPoints) - .ToList(); - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Fetched teams after: {sw.ElapsedMilliseconds}ms"); - var round = await this.context.Rounds - .AsNoTracking() - .Where(r => r.Id == roundId) - .FirstOrDefaultAsync(); - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Fetched round after: {sw.ElapsedMilliseconds}ms"); - var services = this.context.Services - .AsNoTracking() - .OrderBy(s => s.Id) - .ToList(); - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Fetched services after: {sw.ElapsedMilliseconds}ms"); - - var scoreboardTeams = new List(); - var scoreboardServices = new List(); - - foreach (var service in services) - { - var firstBloods = new SubmittedFlag[service.FlagVariants]; - for (int i = 0; i < service.FlagsPerRound; i++) - { - var storeId = i % service.FlagVariants; - var fb = await this.context.SubmittedFlags - .Where(sf => sf.FlagServiceId == service.Id) - .Where(sf => sf.FlagRoundOffset == i) - .OrderBy(sf => sf.Timestamp) - .FirstOrDefaultAsync(); - - if (fb is null) - { - continue; - } - - if (firstBloods[storeId] == null || firstBloods[storeId].Timestamp > fb.Timestamp) - { - firstBloods[storeId] = fb; - } - } - - scoreboardServices.Add(new ScoreboardService( - service.Id, - service.Name, - service.FlagsPerRound, - firstBloods - .Where(sf => sf != null) - .Select(sf => new ScoreboardFirstBlood( - sf.AttackerTeamId, - teams.Where(t => t.Id == sf.AttackerTeamId).First().Name, - sf.Timestamp.ToString(EnoCoreUtil.DateTimeFormat), - sf.RoundId, - sf.FlagRoundOffset % service.FlagVariants)) - .ToArray())); - } - - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Iterated services after: {sw.ElapsedMilliseconds}ms"); - - foreach (var team in teams) - { - scoreboardTeams.Add(new ScoreboardTeam( - team.Name, - team.Id, - team.LogoUrl, - team.CountryCode, - team.TotalPoints, - team.AttackPoints, - team.DefensePoints, - team.ServiceLevelAgreementPoints, - team.TeamServicePoints.Select( - tsp => new ScoreboardTeamServiceDetails( - tsp.ServiceId, - tsp.AttackPoints, - tsp.DefensePoints, - tsp.ServiceLevelAgreementPoints, - tsp.Status, - tsp.ErrorMessage)) - .ToArray())); - } - - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Iterated teams after: {sw.ElapsedMilliseconds}ms"); - - var scoreboard = new Scoreboard( - roundId, - round?.Begin.ToString(EnoCoreUtil.DateTimeFormat), - round?.End.ToString(EnoCoreUtil.DateTimeFormat), - string.Empty, // TODO - scoreboardServices.ToArray(), - scoreboardTeams.ToArray()); - this.logger.LogInformation($"{nameof(this.GetCurrentScoreboard)} Finished after: {sw.ElapsedMilliseconds}ms"); - return scoreboard; - } -} diff --git a/EnoDatabase/EnoDb.cs b/EnoDatabase/EnoDb.cs index eb39b80..e9a790d 100644 --- a/EnoDatabase/EnoDb.cs +++ b/EnoDatabase/EnoDb.cs @@ -1,4 +1,6 @@ -namespace EnoDatabase; +using System.Threading; + +namespace EnoDatabase; public partial class EnoDb { @@ -51,10 +53,8 @@ public async Task CreateNewRound(DateTime begin, DateTime q2, DateTime q3 var round = new Round( roundId, begin, - q2, - q3, - q4, - end); + end, + RoundStatus.Prepared); this.context.Rounds.Add(round); await this.context.SaveChangesAsync(); return round; @@ -106,6 +106,14 @@ public async Task> RetrievePendingCheckerTasks(int maxAmount) return round; } + public async Task GetFirstUnscoreRound(CancellationToken token) + { + return await this.context.Rounds + .OrderBy(f => f.Id) + .Where(e => e.Status == RoundStatus.Finished) + .FirstOrDefaultAsync(token); + } + public async Task InsertPutFlagsTasks( Round round, Team[] activeTeams, @@ -155,7 +163,7 @@ public async Task InsertPutFlagsTasks( round.Id, round.Id, new Flag(team.Id, service.Id, variantIndex, round.Id).ToString(configuration.FlagSigningKey, configuration.Encoding), - taskStart.AddSeconds(taskOffset), + taskStart!.Value.AddSeconds(taskOffset), (int)(quarterLength * 1000), configuration.RoundLengthInSeconds, variantIndex, @@ -196,7 +204,7 @@ public async Task InsertPutNoisesTasks( } var tasks = new List(tasksCount); - var taskStart = round.Begin; + var taskStart = round.Begin!.Value; double teamStepLength = (quarterLength - 2) / activeTeams.Length; double serviceStepLength = teamStepLength / activeServices.Length; foreach (var service in activeServices) @@ -293,7 +301,7 @@ public async Task InsertHavocsTasks( round.Id, round.Id, null, - taskStart.AddSeconds(taskOffset), + taskStart.Value.AddSeconds(taskOffset), (int)(quarterLength * 1000), configuration.RoundLengthInSeconds, variantIndex, @@ -333,6 +341,7 @@ public async Task InsertRetrieveCurrentFlagsTasks( return; } + /*TODO var tasks = new List(tasksCount); var taskStart = round.Quarter3; double teamStepLength = (quarterLength - 2) / activeTeams.Length; @@ -381,6 +390,7 @@ public async Task InsertRetrieveCurrentFlagsTasks( } await this.InsertCheckerTasks(tasks); + */ } public async Task InsertRetrieveOldFlagsTasks( @@ -406,6 +416,7 @@ public async Task InsertRetrieveOldFlagsTasks( } var tasks = new List(tasksCount); + /*TODO var taskStart = round.Quarter2; for (long oldRoundId = round.Id - 1; oldRoundId > (round.Id - configuration.CheckedRoundsPerRound) && oldRoundId > 0; oldRoundId--) @@ -455,6 +466,7 @@ public async Task InsertRetrieveOldFlagsTasks( } await this.InsertCheckerTasks(tasks); + */ } public async Task InsertRetrieveCurrentNoisesTasks( @@ -476,6 +488,7 @@ public async Task InsertRetrieveCurrentNoisesTasks( return; } + /*TODO var tasks = new List(tasksCount); var taskStart = round.Quarter3; double teamStepLength = (quarterLength - 2) / activeTeams.Length; @@ -524,86 +537,7 @@ public async Task InsertRetrieveCurrentNoisesTasks( } await this.InsertCheckerTasks(tasks); - } - - public async Task CalculateRoundTeamServiceStates(IServiceProvider serviceProvider, long roundId, EnoStatistics statistics) - { - var teams = await this.context.Teams.AsNoTracking().ToArrayAsync(); - var services = await this.context.Services.AsNoTracking().ToArrayAsync(); - - var currentRoundWorstResults = new Dictionary<(long ServiceId, long TeamId), CheckerTask?>(); - var sw = new Stopwatch(); - sw.Start(); - var foo = await this.context.CheckerTasks - .TagWith("CalculateRoundTeamServiceStates:currentRoundTasks") - .Where(ct => ct.CurrentRoundId == roundId) - .Where(ct => ct.RelatedRoundId == roundId) - .OrderBy(ct => ct.CheckerResult) - .ThenBy(ct => ct.StartTime) - .ToListAsync(); - foreach (var e in foo) - { - if (!currentRoundWorstResults.ContainsKey((e.ServiceId, e.TeamId))) - { - currentRoundWorstResults[(e.ServiceId, e.TeamId)] = e; - } - } - - sw.Stop(); - statistics.LogCheckerTaskAggregateMessage(roundId, sw.ElapsedMilliseconds); - this.logger.LogInformation($"CalculateRoundTeamServiceStates: Data Aggregation for {teams.Length} Teams and {services.Length} Services took {sw.ElapsedMilliseconds}ms"); - - var oldRoundsWorstResults = await this.context.CheckerTasks - .TagWith("CalculateRoundTeamServiceStates:oldRoundsTasks") - .Where(ct => ct.CurrentRoundId == roundId) - .Where(ct => ct.RelatedRoundId != roundId) - .GroupBy(ct => new { ct.ServiceId, ct.TeamId }) - .Select(g => new { g.Key, WorstResult = g.Min(ct => ct.CheckerResult) }) - .AsNoTracking() - .ToDictionaryAsync(g => g.Key, g => g.WorstResult); - - var newRoundTeamServiceStatus = new Dictionary<(long ServiceId, long TeamId), RoundTeamServiceStatus>(); - foreach (var team in teams) - { - foreach (var service in services) - { - var key2 = (service.Id, team.Id); - var key = new { ServiceId = service.Id, TeamId = team.Id }; - ServiceStatus status = ServiceStatus.INTERNAL_ERROR; - string? message = null; - if (currentRoundWorstResults.ContainsKey(key2)) - { - if (currentRoundWorstResults[key2] != null) - { - status = currentRoundWorstResults[key2]!.CheckerResult.AsServiceStatus(); - message = currentRoundWorstResults[key2]!.ErrorMessage; - } - else - { - status = ServiceStatus.OK; - message = null; - } - } - - if (status == ServiceStatus.OK && oldRoundsWorstResults.ContainsKey(key)) - { - if (oldRoundsWorstResults[key] != CheckerResult.OK) - { - status = ServiceStatus.RECOVERING; - } - } - - newRoundTeamServiceStatus[(key.ServiceId, key.TeamId)] = new RoundTeamServiceStatus( - status, - message, - key.TeamId, - key.ServiceId, - roundId); - } - } - - this.context.RoundTeamServiceStatus.AddRange(newRoundTeamServiceStatus.Values); - await this.context.SaveChangesAsync(); + */ } public async Task UpdateTaskCheckerTaskResults(Memory tasks) diff --git a/EnoDatabase/Migrations/20250201180507_M3.Designer.cs b/EnoDatabase/Migrations/20250201180507_M3.Designer.cs new file mode 100644 index 0000000..47ac489 --- /dev/null +++ b/EnoDatabase/Migrations/20250201180507_M3.Designer.cs @@ -0,0 +1,391 @@ +// +using System; +using EnoDatabase; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EnoDatabase.Migrations +{ + [DbContext(typeof(EnoDbContext))] + [Migration("20250201180507_M3")] + partial class M3 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EnoCore.Models.Database.CheckerTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("AttackInfo") + .HasColumnType("text"); + + b.Property("CheckerResult") + .HasColumnType("integer"); + + b.Property("CheckerTaskLaunchStatus") + .HasColumnType("integer"); + + b.Property("CheckerUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("CurrentRoundId") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("MaxRunningTime") + .HasColumnType("integer"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("RelatedRoundId") + .HasColumnType("bigint"); + + b.Property("RoundLength") + .HasColumnType("bigint"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TeamId") + .HasColumnType("bigint"); + + b.Property("TeamName") + .IsRequired() + .HasColumnType("text"); + + b.Property("UniqueVariantId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CheckerTaskLaunchStatus", "StartTime"); + + b.HasIndex("CurrentRoundId", "RelatedRoundId", "CheckerResult"); + + b.ToTable("CheckerTasks"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.Configuration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedRoundsPerRound") + .HasColumnType("integer"); + + b.Property("DnsSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Encoding") + .HasColumnType("integer"); + + b.Property("FlagSigningKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FlagValidityInRounds") + .HasColumnType("bigint"); + + b.Property("RoundLengthInSeconds") + .HasColumnType("integer"); + + b.Property("TeamSubnetBytesLength") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Configurations"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.Round", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Begin") + .HasColumnType("timestamp with time zone"); + + b.Property("End") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Rounds"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.RoundTeamServiceStatus", b => + { + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("TeamId") + .HasColumnType("bigint"); + + b.Property("GameRoundId") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("ServiceId", "TeamId", "GameRoundId"); + + b.HasIndex("ServiceId", "GameRoundId", "Status"); + + b.ToTable("RoundTeamServiceStatus"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("boolean"); + + b.PrimitiveCollection("Checkers") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FlagVariants") + .HasColumnType("bigint"); + + b.Property("FlagsPerRound") + .HasColumnType("bigint"); + + b.Property("HavocVariants") + .HasColumnType("bigint"); + + b.Property("HavocsPerRound") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NoiseVariants") + .HasColumnType("bigint"); + + b.Property("NoisesPerRound") + .HasColumnType("bigint"); + + b.Property("WeightFactor") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.SubmittedFlag", b => + { + b.Property("FlagServiceId") + .HasColumnType("bigint"); + + b.Property("FlagRoundId") + .HasColumnType("bigint"); + + b.Property("FlagOwnerId") + .HasColumnType("bigint"); + + b.Property("FlagRoundOffset") + .HasColumnType("integer"); + + b.Property("AttackerTeamId") + .HasColumnType("bigint"); + + b.Property("RoundId") + .HasColumnType("bigint"); + + b.Property("SubmissionsCount") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FlagServiceId", "FlagRoundId", "FlagOwnerId", "FlagRoundOffset", "AttackerTeamId"); + + b.HasIndex("FlagServiceId", "AttackerTeamId", "RoundId"); + + b.HasIndex("FlagServiceId", "FlagRoundOffset", "Timestamp"); + + b.HasIndex("FlagServiceId", "FlagOwnerId", "RoundId", "FlagRoundOffset"); + + b.ToTable("SubmittedFlags"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AttackPoints") + .HasColumnType("double precision"); + + b.Property("CountryCode") + .HasColumnType("text"); + + b.Property("DefensePoints") + .HasColumnType("double precision"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLevelAgreementPoints") + .HasColumnType("double precision"); + + b.Property("ServiceStatsId") + .HasColumnType("bigint"); + + b.Property("TeamSubnet") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("TotalPoints") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.TeamServicePoints", b => + { + b.Property("TeamId") + .HasColumnType("bigint"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("AttackPoints") + .HasColumnType("double precision"); + + b.Property("DefensePoints") + .HasColumnType("double precision"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("ServiceLevelAgreementPoints") + .HasColumnType("double precision"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("TeamId", "ServiceId"); + + b.ToTable("TeamServicePoints"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.TeamServicePointsSnapshot", b => + { + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("RoundId") + .HasColumnType("bigint"); + + b.Property("TeamId") + .HasColumnType("bigint"); + + b.Property("AttackPoints") + .HasColumnType("double precision"); + + b.Property("LostDefensePoints") + .HasColumnType("double precision"); + + b.Property("ServiceLevelAgreementPoints") + .HasColumnType("double precision"); + + b.HasKey("ServiceId", "RoundId", "TeamId"); + + b.ToTable("TeamServicePointsSnapshot"); + }); + + modelBuilder.Entity("EnoCore.Models.Database.TeamServicePoints", b => + { + b.HasOne("EnoCore.Models.Database.Team", null) + .WithMany("TeamServicePoints") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EnoCore.Models.Database.Team", b => + { + b.Navigation("TeamServicePoints"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EnoDatabase/Migrations/20250201180507_M3.cs b/EnoDatabase/Migrations/20250201180507_M3.cs new file mode 100644 index 0000000..27d799c --- /dev/null +++ b/EnoDatabase/Migrations/20250201180507_M3.cs @@ -0,0 +1,99 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EnoDatabase.Migrations +{ + /// + public partial class M3 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Quarter2", + table: "Rounds"); + + migrationBuilder.DropColumn( + name: "Quarter3", + table: "Rounds"); + + migrationBuilder.DropColumn( + name: "Quarter4", + table: "Rounds"); + + migrationBuilder.AlterColumn( + name: "End", + table: "Rounds", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "Begin", + table: "Rounds", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone"); + + migrationBuilder.AddColumn( + name: "Status", + table: "Rounds", + type: "integer", + nullable: false, + defaultValue: RoundStatus.Finished); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Status", + table: "Rounds"); + + migrationBuilder.AlterColumn( + name: "End", + table: "Rounds", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Begin", + table: "Rounds", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "Quarter2", + table: "Rounds", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "Quarter3", + table: "Rounds", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + migrationBuilder.AddColumn( + name: "Quarter4", + table: "Rounds", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + } + } +} diff --git a/EnoDatabase/Migrations/EnoDbContextModelSnapshot.cs b/EnoDatabase/Migrations/EnoDbContextModelSnapshot.cs index 314d559..10e4de9 100644 --- a/EnoDatabase/Migrations/EnoDbContextModelSnapshot.cs +++ b/EnoDatabase/Migrations/EnoDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.7") + .HasAnnotation("ProductVersion", "9.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -148,20 +148,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("Begin") + b.Property("Begin") .HasColumnType("timestamp with time zone"); - b.Property("End") + b.Property("End") .HasColumnType("timestamp with time zone"); - b.Property("Quarter2") - .HasColumnType("timestamp with time zone"); - - b.Property("Quarter3") - .HasColumnType("timestamp with time zone"); - - b.Property("Quarter4") - .HasColumnType("timestamp with time zone"); + b.Property("Status") + .HasColumnType("integer"); b.HasKey("Id"); @@ -203,7 +197,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Active") .HasColumnType("boolean"); - b.Property("Checkers") + b.PrimitiveCollection("Checkers") .IsRequired() .HasColumnType("text[]"); diff --git a/EnoEngine.sln b/EnoEngine.sln index 5553bae..76a2f19 100644 --- a/EnoEngine.sln +++ b/EnoEngine.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28803.352 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnoEngine", "EnoEngine\EnoEngine.csproj", "{3574123C-85D5-4A75-8F24-66736C072E8B}" EndProject @@ -29,6 +29,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnoCore.Models", "EnoCore.M EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnoConfig", "EnoConfig\EnoConfig.csproj", "{C4BD7621-4EB3-47A6-B0D2-78F68FC7E100}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnoScoring", "EnoScoring\EnoScoring.csproj", "{0B387D85-F608-4FF4-87CA-BBBD39A8A6C9}" + ProjectSection(ProjectDependencies) = postProject + {0B819339-3BEF-4D7F-9828-B54B132ED79F} = {0B819339-3BEF-4D7F-9828-B54B132ED79F} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +72,10 @@ Global {C4BD7621-4EB3-47A6-B0D2-78F68FC7E100}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4BD7621-4EB3-47A6-B0D2-78F68FC7E100}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4BD7621-4EB3-47A6-B0D2-78F68FC7E100}.Release|Any CPU.Build.0 = Release|Any CPU + {0B387D85-F608-4FF4-87CA-BBBD39A8A6C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B387D85-F608-4FF4-87CA-BBBD39A8A6C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B387D85-F608-4FF4-87CA-BBBD39A8A6C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B387D85-F608-4FF4-87CA-BBBD39A8A6C9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/EnoEngine/EnoEngine.CTF.cs b/EnoEngine/EnoEngine.CTF.cs deleted file mode 100644 index 4b5d96b..0000000 --- a/EnoEngine/EnoEngine.CTF.cs +++ /dev/null @@ -1,136 +0,0 @@ -namespace EnoEngine; - -internal partial class EnoEngine -{ - public async Task StartNewRound() - { - DateTime end; - var stopwatch = new Stopwatch(); - stopwatch.Start(); - this.logger.LogDebug("Starting new Round"); - DateTime begin = DateTime.UtcNow; - Round newRound; - Configuration configuration; - Team[] teams; - Service[] services; - - // start the next round - using (var scope = this.serviceProvider.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - configuration = await db.RetrieveConfiguration(); - double quatherLength = configuration.RoundLengthInSeconds / 4; - DateTime q2 = begin.AddSeconds(quatherLength); - DateTime q3 = begin.AddSeconds(quatherLength * 2); - DateTime q4 = begin.AddSeconds(quatherLength * 3); - end = begin.AddSeconds(quatherLength * 4); - newRound = await db.CreateNewRound(begin, q2, q3, q4, end); - teams = await db.RetrieveActiveTeams(); - services = await db.RetrieveActiveServices(); - } - - this.logger.LogInformation($"CreateNewRound for {newRound.Id} finished ({stopwatch.ElapsedMilliseconds}ms)"); - - // insert put tasks - var insertPutNewFlagsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertPutFlagsTasks(newRound, teams, services, configuration)); - var insertPutNewNoisesTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertPutNoisesTasks(newRound, teams, services, configuration)); - var insertHavocsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertHavocsTasks(newRound, teams, services, configuration)); - - await insertPutNewFlagsTask; - await insertPutNewNoisesTask; - await insertHavocsTask; - - // insert get tasks - var insertRetrieveCurrentFlagsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertRetrieveCurrentFlagsTasks(newRound, teams, services, configuration)); - var insertRetrieveOldFlagsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertRetrieveOldFlagsTasks(newRound, teams, services, configuration)); - var insertGetCurrentNoisesTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertRetrieveCurrentNoisesTasks(newRound, teams, services, configuration)); - - await insertRetrieveCurrentFlagsTask; - await insertRetrieveOldFlagsTask; - await insertGetCurrentNoisesTask; - - this.logger.LogInformation($"Checker tasks for round {newRound.Id} created ({stopwatch.ElapsedMilliseconds}ms)"); - await this.HandleRoundEnd(newRound.Id - 1, configuration); - this.logger.LogInformation($"HandleRoundEnd for round {newRound.Id - 1} finished ({stopwatch.ElapsedMilliseconds}ms)"); - - return end; - } - - public async Task HandleRoundEnd(long roundId, Configuration configuration, bool recalculating = false) - { - if (roundId > 0) - { - if (!recalculating) - { - await this.RecordServiceStates(roundId); - } - - await this.UpdateScores(roundId, configuration); - } - - await this.GenerateAttackInfo(roundId, configuration); - - var jsonStopWatch = new Stopwatch(); - jsonStopWatch.Start(); - var scoreboard = await this.databaseUtil.RetryScopedDatabaseAction(db => db.GetCurrentScoreboard(roundId)); - var json = JsonSerializer.Serialize(scoreboard, EnoCoreUtil.CamelCaseEnumConverterOptions); - File.WriteAllText($"{EnoCoreUtil.DataDirectory}scoreboard{roundId}.json", json); - File.WriteAllText($"{EnoCoreUtil.DataDirectory}scoreboard.json", json); - jsonStopWatch.Stop(); - this.logger.LogInformation($"Scoreboard Generation Took {jsonStopWatch.ElapsedMilliseconds} ms"); - return DateTime.UtcNow; - } - - private async Task RecordServiceStates(long roundId) - { - Stopwatch stopWatch = new Stopwatch(); - stopWatch.Start(); - try - { - await this.databaseUtil.RetryScopedDatabaseAction( - db => db.CalculateRoundTeamServiceStates(this.serviceProvider, roundId, this.statistics)); - } - catch (Exception e) - { - this.logger.LogError($"RecordServiceStates failed because: {e}"); - } - finally - { - stopWatch.Stop(); - - // TODO EnoLogger.LogStatistics(RecordServiceStatesFinishedMessage.Create(roundId, stopWatch.ElapsedMilliseconds)); - this.logger.LogInformation($"RecordServiceStates took {stopWatch.ElapsedMilliseconds}ms"); - } - } - - private async Task UpdateScores(long roundId, Configuration configuration) - { - Stopwatch stopWatch = new Stopwatch(); - stopWatch.Start(); - try - { - using var scope = this.serviceProvider.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.UpdateScores(roundId, configuration); - } - catch (Exception e) - { - this.logger.LogError($"UpdateScores failed because: {e}"); - } - - stopWatch.Stop(); - this.logger.LogInformation($"UpdateScores took {stopWatch.ElapsedMilliseconds}ms"); - } - - private async Task GenerateAttackInfo(long roundId, Configuration configuration) - { - var stopWatch = new Stopwatch(); - stopWatch.Start(); - var attackInfo = await this.databaseUtil.RetryScopedDatabaseAction( - db => db.GetAttackInfo(roundId, configuration.FlagValidityInRounds)); - var json = JsonSerializer.Serialize(attackInfo, EnoCoreUtil.CamelCaseEnumConverterOptions); - File.WriteAllText($"{EnoCoreUtil.DataDirectory}attack.json", json); - stopWatch.Stop(); - this.logger.LogInformation($"Attack Info Generation Took {stopWatch.ElapsedMilliseconds} ms"); - } -} diff --git a/EnoEngine/EnoEngine.cs b/EnoEngine/EnoEngine.cs index b50f5f4..14bbf4f 100644 --- a/EnoEngine/EnoEngine.cs +++ b/EnoEngine/EnoEngine.cs @@ -1,6 +1,6 @@ namespace EnoEngine; -internal partial class EnoEngine +internal class EnoEngine { private static readonly CancellationTokenSource EngineCancelSource = new CancellationTokenSource(); @@ -9,7 +9,7 @@ internal partial class EnoEngine private readonly EnoDbUtil databaseUtil; private readonly EnoStatistics statistics; - public EnoEngine(ILogger logger, IServiceProvider serviceProvider, EnoDbUtil databaseUtil, EnoStatistics enoStatistics) + internal EnoEngine(ILogger logger, IServiceProvider serviceProvider, EnoDbUtil databaseUtil, EnoStatistics enoStatistics) { this.logger = logger; this.serviceProvider = serviceProvider; @@ -30,19 +30,6 @@ internal async Task RunContest() await this.GameLoop(); } - internal async Task RunRecalculation() - { - this.logger.LogInformation("RunRecalculation()"); - var lastFinishedRound = await this.databaseUtil.RetryScopedDatabaseAction(db => db.PrepareRecalculation()); - var config = await this.databaseUtil.ExecuteScopedDatabaseAction(db => db.RetrieveConfiguration()); - - for (int i = 1; i <= lastFinishedRound.Id; i++) - { - this.logger.LogInformation("Calculating round {}", i); - await this.HandleRoundEnd(i, config, true); - } - } - private static async Task DelayUntil(DateTime time, CancellationToken token) { var now = DateTime.UtcNow; @@ -78,17 +65,67 @@ private async Task GameLoop() this.logger.LogInformation("GameLoop finished"); } + private async Task StartNewRound() + { + DateTime end; + var stopwatch = new Stopwatch(); + stopwatch.Start(); + this.logger.LogDebug("Starting new Round"); + DateTime begin = DateTime.UtcNow; + Round newRound; + Configuration configuration; + Team[] teams; + Service[] services; + + // start the next round + using (var scope = this.serviceProvider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + configuration = await db.RetrieveConfiguration(); + double quatherLength = configuration.RoundLengthInSeconds / 4; + DateTime q2 = begin.AddSeconds(quatherLength); + DateTime q3 = begin.AddSeconds(quatherLength * 2); + DateTime q4 = begin.AddSeconds(quatherLength * 3); + end = begin.AddSeconds(quatherLength * 4); + newRound = await db.CreateNewRound(begin, q2, q3, q4, end); + teams = await db.RetrieveActiveTeams(); + services = await db.RetrieveActiveServices(); + } + + this.logger.LogInformation($"CreateNewRound for {newRound.Id} finished ({stopwatch.ElapsedMilliseconds}ms)"); + + // insert put tasks + var insertPutNewFlagsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertPutFlagsTasks(newRound, teams, services, configuration)); + var insertPutNewNoisesTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertPutNoisesTasks(newRound, teams, services, configuration)); + var insertHavocsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertHavocsTasks(newRound, teams, services, configuration)); + + await insertPutNewFlagsTask; + await insertPutNewNoisesTask; + await insertHavocsTask; + + // insert get tasks + var insertRetrieveCurrentFlagsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertRetrieveCurrentFlagsTasks(newRound, teams, services, configuration)); + var insertRetrieveOldFlagsTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertRetrieveOldFlagsTasks(newRound, teams, services, configuration)); + var insertGetCurrentNoisesTask = this.databaseUtil.ExecuteScopedDatabaseActionIgnoreErrors(db => db.InsertRetrieveCurrentNoisesTasks(newRound, teams, services, configuration)); + + await insertRetrieveCurrentFlagsTask; + await insertRetrieveOldFlagsTask; + await insertGetCurrentNoisesTask; + + return end; + } + private async Task AwaitOldRound() { var lastRound = await this.databaseUtil.RetryScopedDatabaseAction(db => db.GetLastRound()); if (lastRound != null) { - var span = lastRound.End.Subtract(DateTime.UtcNow); + var span = lastRound.End!.Value.Subtract(DateTime.UtcNow); if (span.Seconds > 1) { this.logger.LogInformation($"Sleeping until old round ends ({lastRound.End})"); - await Task.Delay(span); + await Task.Delay(span, EngineCancelSource.Token); } } } diff --git a/EnoEngine/Program.cs b/EnoEngine/Program.cs index 51b0448..195df91 100644 --- a/EnoEngine/Program.cs +++ b/EnoEngine/Program.cs @@ -39,19 +39,7 @@ // Go! var engine = serviceProvider.GetRequiredService(); - if (args.Length == 1 && args[0] == MODE_RECALCULATE) - { - engine.RunRecalculation().Wait(); - } - else if (args.Length == 0) - { - engine.RunContest().Wait(); - } - else - { - Console.WriteLine("Invalid arguments"); - return 1; - } + engine.RunContest().Wait(); } finally { diff --git a/EnoFlagSink/FlagSubmissionEndpoint.cs b/EnoFlagSink/FlagSubmissionEndpoint.cs index c1ea12b..7c0b858 100644 --- a/EnoFlagSink/FlagSubmissionEndpoint.cs +++ b/EnoFlagSink/FlagSubmissionEndpoint.cs @@ -1,6 +1,6 @@ namespace EnoFlagSink; -public class FlagSubmissionEndpoint +public class FlagSubmissionEndpoint : IDisposable { private const int MaxLineLength = 200; private const int SubmissionBatchSize = 500; @@ -261,4 +261,9 @@ private async Task InsertSubmissionsLoop(int number, Configuration configuration this.logger.LogCritical($"InsertSubmissionsLoop failed: {e.ToFancyStringWithCaller()}"); } } + + public void Dispose() + { + throw new NotImplementedException(); + } } diff --git a/EnoLauncher/Program.cs b/EnoLauncher/Program.cs index 71ff920..bbfb29f 100644 --- a/EnoLauncher/Program.cs +++ b/EnoLauncher/Program.cs @@ -161,7 +161,8 @@ public async Task LaunchCheckerTask(CheckerTask task) errorMessage = null; } - CheckerTask updatedTask = task with { + CheckerTask updatedTask = task with + { CheckerResult = checkerResult, ErrorMessage = errorMessage, AttackInfo = attackInfo, diff --git a/EnoScoring/EnoScoring.csproj b/EnoScoring/EnoScoring.csproj new file mode 100644 index 0000000..c3ffdcf --- /dev/null +++ b/EnoScoring/EnoScoring.csproj @@ -0,0 +1,15 @@ + + + + Exe + + + + + + + + + + + diff --git a/EnoScoring/Program.cs b/EnoScoring/Program.cs new file mode 100644 index 0000000..61064bb --- /dev/null +++ b/EnoScoring/Program.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using EEnoCore.Models.AttackInfo; +using EnoCore; +using EnoCore.Logging; +using EnoCore.Models.Database; +using EnoCore.Models.Scoreboard; +using EnoDatabase; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EnoScoring; + +internal class EnoScoring +{ + private static readonly CancellationTokenSource ScoringCancelSource = new CancellationTokenSource(); + private readonly ILogger logger; + private readonly IDbContextFactory dbContextFactory; + private readonly EnoStatistics statistics; + private const double SLA = 100.0; + private const double ATTACK = 1000.0; + private const double DEF = -50; + + public EnoScoring( + ILogger logger, + IDbContextFactory dbContextFactory, + EnoStatistics statistics) + { + this.logger = logger; + this.dbContextFactory = dbContextFactory; + this.statistics = statistics; + } + + public async Task Run() + { + logger.LogInformation("EnoScoring starting"); + using var debugCtx = await this.dbContextFactory.CreateDbContextAsync(ScoringCancelSource.Token); + //await debugCtx.Database.MigrateAsync(ScoringCancelSource.Token); + await debugCtx.Rounds.Where(e => e.Id > 400).ExecuteUpdateAsync(e => e.SetProperty(e => e.Status, RoundStatus.Finished)); + + logger.LogInformation("EnoScoring looping"); + try + { + while (!ScoringCancelSource.IsCancellationRequested) + { + var round = await this.GetNextRound(); + if (round == null) + { + await Task.Delay(100); + continue; + } + + this.logger.LogInformation("Scoring round {}", round.Id); + Stopwatch stopWatch = Stopwatch.StartNew(); + var configuration = await this.GetConfiguration(); + await this.DoRoundTeamServiceStates(round.Id, this.statistics); + await this.DoScores(round.Id, configuration); + await this.DoAttackInfo(round.Id, configuration); + await this.DoCurrentScoreboard(round.Id); + await debugCtx.Rounds.Where(e => e.Id == round.Id).ExecuteUpdateAsync(e => e.SetProperty(e => e.Status, RoundStatus.Scored)); + stopWatch.Stop(); + this.logger.LogInformation("Scoring round {} took {}ms", round.Id, stopWatch.ElapsedMilliseconds); + } + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + this.logger.LogError($"Scoring failed: {e.ToFancyStringWithCaller()}"); + } + this.logger.LogInformation("EnoScoring starting finished"); + } + + private async Task GetNextRound() + { + using var ctx = this.dbContextFactory.CreateDbContext(); + return await ctx.Rounds + .AsNoTracking() + .OrderBy(e => e.Id) + .Where(e => e.Status == RoundStatus.Finished) + .FirstOrDefaultAsync(ScoringCancelSource.Token); + } + + private async Task GetConfiguration() + { + using var ctx = this.dbContextFactory.CreateDbContext(); + return await ctx.Configurations.AsNoTracking().SingleAsync(); + } + + #region Phase 1 (Service States) + /// + /// Determine which services were in which state (OK, DOWN, MUMBLE, ... + /// + /// + /// + /// + private async Task DoRoundTeamServiceStates(long roundId, EnoStatistics statistics) + { + var sw = Stopwatch.StartNew(); + using var ctx = this.dbContextFactory.CreateDbContext(); + await ctx.RoundTeamServiceStatus.Where(e => e.GameRoundId >= roundId).ExecuteDeleteAsync(ScoringCancelSource.Token); + var teams = await ctx.Teams.AsNoTracking().ToArrayAsync(); + var services = await ctx.Services.AsNoTracking().ToArrayAsync(); + + var currentRoundWorstResults = new Dictionary<(long ServiceId, long TeamId), CheckerTask?>(); + var currentTasks = await ctx.CheckerTasks + .TagWith("CalculateRoundTeamServiceStates:currentRoundTasks") + .Where(ct => ct.CurrentRoundId == roundId) + .Where(ct => ct.RelatedRoundId == roundId) + .OrderBy(ct => ct.CheckerResult) + .ThenBy(ct => ct.StartTime) + .AsNoTracking() + .ToListAsync(); + foreach (var e in currentTasks) + { + if (!currentRoundWorstResults.ContainsKey((e.ServiceId, e.TeamId))) + { + currentRoundWorstResults[(e.ServiceId, e.TeamId)] = e; + } + } + + sw.Stop(); + statistics.LogCheckerTaskAggregateMessage(roundId, sw.ElapsedMilliseconds); + + var oldRoundsWorstResults = await ctx.CheckerTasks + .TagWith("CalculateRoundTeamServiceStates:oldRoundsTasks") + .Where(ct => ct.CurrentRoundId == roundId) + .Where(ct => ct.RelatedRoundId != roundId) + .GroupBy(ct => new { ct.ServiceId, ct.TeamId }) + .Select(g => new { g.Key, WorstResult = g.Min(ct => ct.CheckerResult) }) + .AsNoTracking() + .ToDictionaryAsync(g => g.Key, g => g.WorstResult); + + var newRoundTeamServiceStatus = new Dictionary<(long ServiceId, long TeamId), RoundTeamServiceStatus>(); + foreach (var team in teams) + { + foreach (var service in services) + { + var key2 = (service.Id, team.Id); + var key = new { ServiceId = service.Id, TeamId = team.Id }; + ServiceStatus status = ServiceStatus.INTERNAL_ERROR; + string? message = null; + if (currentRoundWorstResults.ContainsKey(key2)) + { + if (currentRoundWorstResults[key2] != null) + { + status = currentRoundWorstResults[key2]!.CheckerResult.AsServiceStatus(); + message = currentRoundWorstResults[key2]!.ErrorMessage; + } + else + { + status = ServiceStatus.OK; + message = null; + } + } + + if (status == ServiceStatus.OK && oldRoundsWorstResults.ContainsKey(key)) + { + if (oldRoundsWorstResults[key] != CheckerResult.OK) + { + status = ServiceStatus.RECOVERING; + } + } + + newRoundTeamServiceStatus[(key.ServiceId, key.TeamId)] = new RoundTeamServiceStatus( + status, + message, + key.TeamId, + key.ServiceId, + roundId); + } + } + + ctx.RoundTeamServiceStatus.AddRange(newRoundTeamServiceStatus.Values); + await ctx.SaveChangesAsync(); + this.logger.LogInformation($"{nameof(DoRoundTeamServiceStates)} took {sw.ElapsedMilliseconds}ms"); + } + #endregion + + #region Phase 2 (Scores) + private async Task DoScores(long roundId, Configuration configuration) + { + var sw = Stopwatch.StartNew(); + using var ctx = this.dbContextFactory.CreateDbContext(); + double servicesWeightFactor = await ctx.Services.Where(s => s.Active).SumAsync(s => s.WeightFactor); + double storeWeightFactor = await ctx.Services.Where(s => s.Active).SumAsync(s => s.WeightFactor * s.FlagVariants); + var newSnapshotRoundId = roundId - configuration.FlagValidityInRounds - 5; + await ctx.TeamServicePointsSnapshot.Where(e => e.RoundId >= newSnapshotRoundId).ExecuteDeleteAsync(ScoringCancelSource.Token); + + // Phase 2: Create new TeamServicePointsSnapshots, if required + sw.Restart(); + if (newSnapshotRoundId > 0) + { + var query = this.GetQuery(ctx, newSnapshotRoundId, newSnapshotRoundId, storeWeightFactor, servicesWeightFactor); + var phase2QueryRaw = @$" +WITH cte AS ( + SELECT ""TeamId"", ""ServiceId"", ""RoundId"", ""AttackPoints"", ""LostDefensePoints"", ""ServiceLevelAgreementPoints"" + FROM ( +----------------- +{query} +----------------- + ) as k +) +INSERT INTO ""TeamServicePointsSnapshot"" (""TeamId"", ""ServiceId"", ""RoundId"", ""AttackPoints"", ""LostDefensePoints"", ""ServiceLevelAgreementPoints"") -- Mind that the order is important! +SELECT * FROM cte +"; + await ctx.Database.ExecuteSqlRawAsync(phase2QueryRaw); + } + + var phase3Query = this.GetQuery(ctx, newSnapshotRoundId + 1, roundId, storeWeightFactor, servicesWeightFactor); + var phase3QueryRaw = @$" +WITH cte AS ( +----------------- +{phase3Query} +----------------- +) +UPDATE + ""TeamServicePoints"" +SET + ""AttackPoints"" = cte.""AttackPoints"", + ""DefensePoints"" = cte.""LostDefensePoints"", + ""ServiceLevelAgreementPoints"" = cte.""ServiceLevelAgreementPoints"", + ""Status"" = cte.""Status"", + ""ErrorMessage"" = cte.""ErrorMessage"" +FROM cte +WHERE + ""TeamServicePoints"".""TeamId"" = cte.""TeamId"" AND + ""TeamServicePoints"".""ServiceId"" = cte.""ServiceId"" +;"; + await ctx.Database.ExecuteSqlRawAsync(phase3QueryRaw); + + foreach (var team in await ctx.Teams.ToArrayAsync()) + { + team.AttackPoints = await ctx.TeamServicePoints + .Where(e => e.TeamId == team.Id) + .Select(e => e.AttackPoints) + .SumAsync(); + + team.DefensePoints = await ctx.TeamServicePoints + .Where(e => e.TeamId == team.Id) + .Select(e => e.DefensePoints) + .SumAsync(); + + team.ServiceLevelAgreementPoints = await ctx.TeamServicePoints + .Where(e => e.TeamId == team.Id) + .Select(e => e.ServiceLevelAgreementPoints) + .SumAsync(); + + team.TotalPoints = team.AttackPoints + team.DefensePoints + team.ServiceLevelAgreementPoints; + } + + await ctx.SaveChangesAsync(); + this.logger.LogInformation($"{nameof(DoScores)} took {sw.ElapsedMilliseconds}ms"); + } + + private string GetQuery(EnoDbContext ctx, long minRoundId, long maxRoundId, double storeWeightFactor, double servicesWeightFactor) + { + Debug.Assert(storeWeightFactor > 0, "Invalid store weight"); + Debug.Assert(servicesWeightFactor > 0, "Invalid services weight"); + long oldSnapshotRoundId = minRoundId - 1; + var query = + from team in ctx.Teams + from service in ctx.Services + select new + { + TeamId = team.Id, + ServiceId = service.Id, + RoundId = maxRoundId, + AttackPoints = ctx.SubmittedFlags // service, attacker, round + .Where(sf => sf.FlagServiceId == service.Id) + .Where(sf => sf.AttackerTeamId == team.Id) + .Where(sf => sf.RoundId <= maxRoundId) + .Where(sf => sf.RoundId >= minRoundId) + .Sum(sf => ATTACK + * ctx.Services.Where(e => e.Id == service.Id).Single().WeightFactor / servicesWeightFactor // Service Weight Scaling + / ctx.Services.Where(e => e.Id == service.Id).Single().FlagsPerRound + / ctx.Services.Where(e => e.Id == service.Id).Single().FlagVariants + / ctx.SubmittedFlags // service, owner, round (, offset) + .Where(e => e.FlagServiceId == sf.FlagServiceId) + .Where(e => e.FlagOwnerId == sf.FlagOwnerId) + .Where(e => e.FlagRoundId == sf.FlagRoundId) + .Where(e => e.FlagRoundOffset == sf.FlagRoundOffset) + .Count() // Other attackers + / ctx.Teams.Where(e => e.Active).Count()) + + Math.Max( + ctx.TeamServicePointsSnapshot + .Where(e => e.RoundId == oldSnapshotRoundId) + .Where(e => e.TeamId == team.Id) + .Where(e => e.ServiceId == service.Id) + .Single().AttackPoints, + 0.0), + LostDefensePoints = (DEF + * ctx.Services.Where(e => e.Id == service.Id).Single().WeightFactor / servicesWeightFactor + / ctx.Services.Where(e => e.Id == service.Id).Single().FlagsPerRound + * ctx.SubmittedFlags // service, owner, round + .Where(e => e.FlagServiceId == service.Id) + .Where(e => e.FlagOwnerId == team.Id) + .Where(e => e.FlagRoundId <= maxRoundId) + .Where(e => e.FlagRoundId >= minRoundId) + .Select(e => new { e.FlagServiceId, e.FlagOwnerId, e.FlagRoundId, e.FlagRoundOffset }) + .Distinct() // Lost flags + .Count()) + + Math.Min( + ctx.TeamServicePointsSnapshot + .Where(e => e.RoundId == oldSnapshotRoundId) + .Where(e => e.TeamId == team.Id) + .Where(e => e.ServiceId == service.Id) + .Single().LostDefensePoints, + 0.0), + ServiceLevelAgreementPoints = ctx.RoundTeamServiceStatus + .Where(e => e.GameRoundId <= maxRoundId) + .Where(e => e.GameRoundId >= minRoundId) + .Where(e => e.TeamId == team.Id) + .Where(e => e.ServiceId == service.Id) + .Sum(sla => SLA + * ctx.Services.Where(s => s.Id == s.Id).Single().WeightFactor + * (sla.Status == ServiceStatus.OK ? 1 : sla.Status == ServiceStatus.RECOVERING ? 0.5 : 0) + / servicesWeightFactor) + + Math.Max( + ctx.TeamServicePointsSnapshot + .Where(e => e.RoundId == oldSnapshotRoundId) + .Where(e => e.TeamId == team.Id) + .Where(e => e.ServiceId == service.Id) + .Single().ServiceLevelAgreementPoints, + 0.0), + Status = ctx.RoundTeamServiceStatus + .Where(e => e.GameRoundId == maxRoundId) + .Where(e => e.TeamId == team.Id) + .Where(e => e.ServiceId == service.Id) + .Select(e => e.Status) + .Single(), + ErrorMessage = ctx.RoundTeamServiceStatus + .Where(e => e.GameRoundId == maxRoundId) + .Where(e => e.TeamId == team.Id) + .Where(e => e.ServiceId == service.Id) + .Select(e => e.ErrorMessage) + .Single(), + }; + + var queryString = query.ToQueryString(); + queryString = queryString.Replace("@__maxRoundId_0", maxRoundId.ToString()); + queryString = queryString.Replace("@__minRoundId_1", minRoundId.ToString()); + queryString = queryString.Replace("@__servicesWeightFactor_2", servicesWeightFactor.ToString()); + queryString = queryString.Replace("@__oldSnapshotRoundId_3", (minRoundId - 1).ToString()); + queryString = queryString.Replace("@__storeWeightFactor_4", storeWeightFactor.ToString()); + return queryString; + } + #endregion + + #region Phase 3 (Attack Info) + private async Task DoAttackInfo(long roundId, Configuration configuration) + { + var sw = Stopwatch.StartNew(); + using var ctx = this.dbContextFactory.CreateDbContext(); + var attackInfo = await this.GetAttackInfo(roundId, configuration.FlagValidityInRounds); + var json = JsonSerializer.Serialize(attackInfo, EnoCoreUtil.CamelCaseEnumConverterOptions); + File.WriteAllText($"{EnoCoreUtil.DataDirectory}attack.json", json); + this.logger.LogInformation($"{nameof(DoAttackInfo)} took {sw.ElapsedMilliseconds}ms"); + } + + private async Task GetAttackInfo(long roundId, long flagValidityInRounds) + { + using var ctx = await this.dbContextFactory.CreateDbContextAsync(); + var teamAddresses = await ctx.Teams + .AsNoTracking() + .Select(t => new { t.Id, t.Address }) + .ToDictionaryAsync(t => t.Id, t => t.Address); + var availableTeams = await ctx.RoundTeamServiceStatus + .Where(rtss => rtss.GameRoundId == roundId) + .GroupBy(rtss => rtss.TeamId) + .Select(g => new { g.Key, BestResult = g.Min(rtss => rtss.Status) }) + .Where(ts => ts.BestResult < ServiceStatus.OFFLINE) + .Select(ts => ts.Key) + .OrderBy(ts => ts) + .ToArrayAsync(); + var availableTeamAddresses = availableTeams.Select(id => teamAddresses[id] ?? id.ToString()).ToArray(); + + var serviceNames = await ctx.Services + .AsNoTracking() + .Select(s => new { s.Id, s.Name }) + .ToDictionaryAsync(s => s.Id, s => s.Name); + + var relevantTasks = await ctx.CheckerTasks + .AsNoTracking() + .Where(ct => ct.CurrentRoundId > roundId - flagValidityInRounds) + .Where(ct => ct.CurrentRoundId <= roundId) + .Where(ct => ct.Method == CheckerTaskMethod.putflag) + .Where(ct => ct.AttackInfo != null) + .Select(ct => new { ct.AttackInfo, ct.VariantId, ct.CurrentRoundId, ct.TeamId, ct.ServiceId }) + .OrderBy(ct => ct.ServiceId) + .ThenBy(ct => ct.TeamId) + .ThenBy(ct => ct.CurrentRoundId) + .ThenBy(ct => ct.VariantId) + .ToArrayAsync(); + var groupedTasks = relevantTasks + .GroupBy(ct => new { ct.VariantId, ct.CurrentRoundId, ct.TeamId, ct.ServiceId }) + .GroupBy(g => new { g.Key.CurrentRoundId, g.Key.TeamId, g.Key.ServiceId }) + .GroupBy(g => new { g.Key.TeamId, g.Key.ServiceId }) + .GroupBy(g => new { g.Key.ServiceId }); + + var services = new Dictionary(); + foreach (var serviceTasks in groupedTasks) + { + var service = new AttackInfoService(); + foreach (var teamTasks in serviceTasks) + { + var team = new AttackInfoServiceTeam(); + foreach (var roundTasks in teamTasks) + { + var round = new AttackInfoServiceTeamRound(); + foreach (var variantTasks in roundTasks) + { + string[] attackInfos = variantTasks.Select(ct => ct.AttackInfo!).ToArray(); + round.Add(variantTasks.Key.VariantId, attackInfos); + } + + team.Add(roundTasks.Key.CurrentRoundId, round); + } + + service.TryAdd(teamAddresses[teamTasks.Key.TeamId] ?? teamTasks.Key.TeamId.ToString(), team); + } + + services.TryAdd(serviceNames[serviceTasks.Key.ServiceId] ?? serviceTasks.Key.ServiceId.ToString(), service); + } + + var attackInfo = new AttackInfo(availableTeamAddresses, services); + return attackInfo; + } + #endregion + + #region Phase 4 (Scoreboard) + private async Task DoCurrentScoreboard(long roundId) + { + var sw = Stopwatch.StartNew(); + using var ctx = this.dbContextFactory.CreateDbContext(); + var teams = ctx.Teams + .Include(t => t.TeamServicePoints) + .AsNoTracking() + .OrderByDescending(t => t.TotalPoints) + .ToList(); + var round = await ctx.Rounds + .AsNoTracking() + .Where(r => r.Id == roundId) + .FirstOrDefaultAsync(); + var services = ctx.Services + .AsNoTracking() + .OrderBy(s => s.Id) + .ToList(); + + var scoreboardTeams = new List(); + var scoreboardServices = new List(); + + foreach (var service in services) + { + var firstBloods = new SubmittedFlag[service.FlagVariants]; + for (int i = 0; i < service.FlagsPerRound; i++) + { + var storeId = i % service.FlagVariants; + var fb = await ctx.SubmittedFlags + .Where(sf => sf.FlagServiceId == service.Id) + .Where(sf => sf.FlagRoundOffset == i) + .OrderBy(sf => sf.Timestamp) + .FirstOrDefaultAsync(); + + if (fb is null) + { + continue; + } + + if (firstBloods[storeId] == null || firstBloods[storeId].Timestamp > fb.Timestamp) + { + firstBloods[storeId] = fb; + } + } + + scoreboardServices.Add(new ScoreboardService( + service.Id, + service.Name, + service.FlagsPerRound, + firstBloods + .Where(sf => sf != null) + .Select(sf => new ScoreboardFirstBlood( + sf.AttackerTeamId, + teams.Where(t => t.Id == sf.AttackerTeamId).First().Name, + sf.Timestamp.ToString(EnoCoreUtil.DateTimeFormat), + sf.RoundId, + sf.FlagRoundOffset % service.FlagVariants)) + .ToArray())); + } + + foreach (var team in teams) + { + scoreboardTeams.Add(new ScoreboardTeam( + team.Name, + team.Id, + team.LogoUrl, + team.CountryCode, + team.TotalPoints, + team.AttackPoints, + team.DefensePoints, + team.ServiceLevelAgreementPoints, + team.TeamServicePoints.Select( + tsp => new ScoreboardTeamServiceDetails( + tsp.ServiceId, + tsp.AttackPoints, + tsp.DefensePoints, + tsp.ServiceLevelAgreementPoints, + tsp.Status, + tsp.ErrorMessage)) + .ToArray())); + } + + var scoreboard = new Scoreboard( + roundId, + round?.Begin!.Value.ToString(EnoCoreUtil.DateTimeFormat), + round?.End!.Value.ToString(EnoCoreUtil.DateTimeFormat), + string.Empty, // TODO + scoreboardServices.ToArray(), + scoreboardTeams.ToArray()); + + var json = JsonSerializer.Serialize(scoreboard, EnoCoreUtil.CamelCaseEnumConverterOptions); + File.WriteAllText($"{EnoCoreUtil.DataDirectory}scoreboard{round!.Id}.json", json); + File.WriteAllText($"{EnoCoreUtil.DataDirectory}scoreboard.json", json); + this.logger.LogInformation($"{nameof(this.DoCurrentScoreboard)} took: {sw.ElapsedMilliseconds}ms"); + } + #endregion + + public static async Task Main(string[] args) + { + var rootCommand = new RootCommand("EnoScoring"); + rootCommand.SetHandler(async handler => + { + var serviceProvider = new ServiceCollection() + .AddLogging(loggingBuilder => + { + loggingBuilder.SetMinimumLevel(LogLevel.Debug); + loggingBuilder.AddFilter(DbLoggerCategory.Name, LogLevel.None); + loggingBuilder.AddSimpleConsole(options => + { + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + }); + loggingBuilder.AddProvider(new EnoLogMessageFileLoggerProvider("EnoScoring", ScoringCancelSource.Token)); + }) + .AddSingleton() + .AddSingleton(new EnoStatistics(nameof(EnoScoring))) + .AddPooledDbContextFactory( + options => + { + options.UseNpgsql(EnoDbContext.PostgresConnectionString); + }) + .BuildServiceProvider(validateScopes: true); + + await serviceProvider.GetRequiredService().Run(); + }); + await rootCommand.InvokeAsync(args); + } +} diff --git a/README.md b/README.md index 12c421f..5895662 100644 --- a/README.md +++ b/README.md @@ -12,96 +12,18 @@ For performance reasons, it's written in C#. 4. Run EnoConfig to apply the configuration (`dotnet run --project EnoConfig apply`) 5. Run EnoLauncher (`dotnet run -c Release --project EnoLauncher`) 6. Run EnoFlagSink (`dotnet run -c Release --project EnoFlagSink`) +6. Run EnoScoring (`dotnet run -c Release --project EnoScoring`) 7. Once you want to start the CTF (i.e. distribute flags): run EnoEngine (`dotnet run -c Release --project EnoEngine`) -## ctf.json Format -```ts -interface ctfjson { - title: string; - flagValidityInRounds: number; - checkedRoundsPerRound: number; - roundLengthInSeconds: number; - dnsSuffix: string; - teamSubnetBytesLength: number; - flagSigningKey: string; - encoding: string | null; - services: Service[]; - teams: Team[]; -} -interface Service { - id: number; - name: string; - flagsPerRoundMultiplier: number; - noisesPerRoundMultiplier: number; - havocsPerRoundMultiplier: number; - weightFactor: number; - active: string | null; - checkers: string[]; -} - -interface Team { - id: number; - name: string; - address: string | null; - teamSubnet: string; - logoUrl: string | null; - countryCode: string | null; - active: string | null; -} -``` - -## Development -1. Install the dotnet sdk-5. [Download](https://dotnet.microsoft.com/download/visual-studio-sdks) -2. Use any IDE you like (Visual Studio or VSCode recommended) -3. If your IDE doesn't do it automatically, run `dotnet restore` ## Database For creating a migration after changes, run this: ``` cd ./EnoDatabase rm -r Migrations -dotnet ef migrations add InitialMigrations --startup-project ../EnoEngine -``` - -## Checker API v2 -Checkers are expected to respond to these requests, providing a HTTP Status Code 200: - -### `GET /service` -Response: -```ts -interface CheckerInfoMessage { - serviceName: string; // Name of the service - flagVariants: number; // Number of different variants supported for storing/retrieving flags. Each variant must correspond to a different location/flag store in the service. - noiseVariants: number; // Number of different variants supported for storing/retrieving noise. Different variants must not necessarily store the noise in different locations. - havocVariants: number; // Number of different variants supported for havoc. -} +dotnet ef migrations add Mfoo ``` -### `POST /` -Parameter: -```ts -interface CheckerTaskMessage { - taskId: number; // The per-ctf unique id of a task. - method: "putflag" | "getflag" | "putnoise" | "getnoise" | "havoc"; - address: string; // The address of the target team's vulnbox. Can be either an IP address or a valid hostname. - teamId: number; // The id of the target team. - teamName: string; // The name of the target team. - currentRoundId: number; // The id of the current round. - relatedRoundId: number; // For "getflag" and "getnoise", this is the id of the round in which the corresponding "putflag" or "putnoise" happened. For "putflag", "putnoise" and "havoc", this is always identical to currentRoundId. Use the taskChainId to store/retrieve data related to the corresponding "putflag" or "putnoise" instead of using relatedRoundId directly. - flag: string | null; // The flag for putflag and getflag, otherwise null. - variantId: number; // The variant id of the task. Used to support different flag, noise and havoc methods. Starts at 0. - timeout: number; // Timeout for the task in milliseconds. - roundLength: number; // Round length in milliseconds. - taskChainId: string; // The unique identifier of a chain of tasks (i.e. putflag and getflags or putnoise and getnoise for the same flag/noise share an Id, each havoc has its own Id). Should be used in the database to store e.g. credentials created during putlfag and required in getflag. It is up to the caller to ensure the aforementioned criteria are met, the Engine achieves this by composing it the following way: "{flag|noise|havoc}_s{serviceId}_r{relatedRoundId}_t{teamId}_i{uniqueVariantIndex}". A checker may be called multiple times with the same method, serviceId, roundId, teamId and variantId, in which case the uniqueVariantIndex can be used to distinguish the taskChains. -} -``` -Response: -```ts -interface CheckerResultMessage { - result: string; // "INTERNAL_ERROR", "OK", MUMBLE", or "OFFLINE". - message: string | null; // message describing the error, displayed on the public scoreboard if not null -} -``` ## Scoreboard API ```ts